// @ts-check /** * The page file: one self-contained SVG file per page. * * The file serves two readers. Any web browser, or Inkscape, shows the page * from the ordinary SVG shapes. Kokkoja reads the page back exactly from * `data-` attributes on the same elements, so removing a layer or a stroke * from the file removes both what is shown and what Kokkoja reads. * * * the paper * one for each image * * * what is shown * * * the text as typed * one line as shown... * * * * * This is an experiment (see CLAUDE.md, "Not yet verified"). */ import { fromBase64, toBase64 } from './crypto.js'; import { POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId, } from './page_model.js'; import { partAt } from './stroke_geometry.js'; import { LEFT_GAP, baselineOf } from './text_layout.js'; /** @typedef {import('./page_model.js').Stroke} Stroke */ const FORMAT_VERSION = '1'; /** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */ export const TEXT_FRAME_COLOR = '#e53935'; export const TEXT_FRAME_WIDTH = 2; /** * Free text is marked by an L at its top-left corner, two segments this * long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09). */ export const TEXT_MARK = 50; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; /** * The base64 form of each image, so that it is computed once and not at * every save. * @type {WeakMap} */ const imageText = new WeakMap(); /** * @param {import('./page_model.js').PageImage} image * @returns {string} The element that shows the image, or '' if its bytes * have not arrived. */ function imageElement(image) { if (!image.data) return ''; let text = imageText.get(image.data); if (text === undefined) { text = toBase64(image.data); imageText.set(image.data, text); } return ``; } /** * @param {string} text * @returns {string} The text, safe inside a double-quoted XML attribute. */ function escaped(text) { return text.replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } /** * @param {string} text * @returns {string} The text, safe as XML content. */ function escapedContent(text) { return text.replace(/&/g, '&').replace(//g, '>'); } /** * The elements that show and record a text block. * @param {import('./page_model.js').TextBlock} block * @returns {string} */ function textElements(block) { // Each line at its baseline, placed as on the screen. const lines = block.lines.map((line, i) => `${escapedContent(line)}`); const bounds = textBounds(block); // A text rectangle's red frame, or free text's corner mark, is part of // the page (a rectangle's white ground is written beneath all layers: // see pageToSvg). const frame = block.height === null ? `` : ``; return `` + `${escapedContent(block.text)}${frame}` + `${lines.join('')}`; } /** * @param {number} value * @returns {string} The number with at most two decimals. */ function number(value) { return String(Math.round(value * 100) / 100); } /** * The shapes that show a stroke. Consecutive parts of the same width (to * 0.05 mm, far finer than the eye or a printer resolves) are joined into * one path, since each part begins where the one before it ended. The * exact pressures are in `data-points`, so nothing is lost by this. * @param {Stroke} stroke * @returns {string} */ function strokeShapes(stroke) { const count = pointCount(stroke); if (count === 0) return ''; if (count === 1) { const dot = partAt(stroke, 0); return ``; } let shapes = ''; let path = ''; let pathWidth = ''; for (let k = 0; k < count; k++) { const part = partAt(stroke, k); const width = (Math.round(part.width * 2) / 2).toString(); if (width !== pathWidth) { if (path) shapes += ``; path = `M${number(part.fromX)} ${number(part.fromY)}`; pathWidth = width; } path += `Q${number(part.x)} ${number(part.y)} ${number(part.toX)} ${number(part.toY)}`; } return shapes + ``; } /** * @param {Page} page * @param {object} options * @param {string} options.session The session the page belongs to (its * room id), so that returning to the session continues the page. * @param {string} options.hostId * @param {Set} [options.hidden] Layers to leave out: for a picture * of the page as someone shows it, never for the page file itself. * @returns {string} The page file. */ export function pageToSvg(page, { session, hostId, hidden }) { const lines = [ '', ``, `Kokkoja page ${page.number}`, `', ]; for (const image of page.images) { const shown = imageElement(image); if (shown) lines.push(shown); } // The white grounds of the text rectangles: above the images, beneath // every layer, so that they hide the image and never anyone's writing. const grounds = []; for (const id of page.layerOrder(hostId)) { const layer = page.layers.get(id); if (!layer || hidden?.has(id)) continue; for (const block of layer.texts.values()) { if (block.height === null) continue; const b = textBounds(block); grounds.push(``); } } if (grounds.length) lines.push('', ...grounds, ''); for (const id of page.layerOrder(hostId)) { const layer = page.layers.get(id); if (!layer || layer.isEmpty() || hidden?.has(id)) continue; lines.push(``); for (const stroke of layer.strokes.values()) { if (pointCount(stroke) === 0) continue; lines.push( `${strokeShapes(stroke)}`); } for (const block of layer.texts.values()) lines.push(textElements(block)); lines.push(''); } // Hide rectangles: white, above all writing. if (page.hides.length) { lines.push(''); for (const hide of page.hides) { lines.push(``); } lines.push(''); } lines.push('', ''); return lines.join('\n'); } /** * Read a page file. * @param {string} text * @returns {{page: Page, hostId: string, session: string} | null} Null if * the text is not a Kokkoja page. */ export function pageFromSvg(text) { const root = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement; if (root.localName !== 'svg' || root.getAttribute('data-kokkoja') !== FORMAT_VERSION) return null; const box = (root.getAttribute('viewBox') ?? '').split(/\s+/).map(Number); if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null; const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number); // data-base: the sheet's width and height, then its left and top edges // when they are not 0 (a sheet changed by "Widen" or "Shrink"). const descriptor = cleanPageDescriptor({ id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')), width: base.length >= 2 ? base[0] : box[2], height: base.length >= 2 ? base[1] : box[3], left: base.length >= 3 ? base[2] : 0, top: base.length >= 4 ? base[3] : 0, revision: Number(root.getAttribute('data-revision') ?? 0), }); if (!descriptor) return null; const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); page.baseLeft = descriptor.left; page.baseTop = descriptor.top; page.imagesRevision = descriptor.revision; /** @type {Map>} An image shown twice is held once. */ const held = new Map(); for (const shown of root.querySelectorAll('image[data-hash]')) { const address = /^data:(image\/png|image\/jpeg);base64,(.*)$/s.exec(shown.getAttribute('href') ?? ''); if (!address) continue; const hash = shown.getAttribute('data-hash') ?? ''; const data = held.get(hash) ?? fromBase64(address[2]); const image = cleanImage({ type: address[1], size: data.length, hash, x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')), width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')), by: shown.getAttribute('data-by') ?? '', }); if (!image) continue; held.set(hash, data); page.images.push({ ...image, data }); } for (const shown of root.querySelectorAll('g[data-hides] rect[data-hide]')) { const hide = cleanHide({ id: shown.getAttribute('data-hide'), x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')), width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')), }); if (hide) page.hides.push(hide); } for (const group of root.querySelectorAll('g[data-layer]')) { const layerId = group.getAttribute('data-layer'); if (!validId(layerId)) continue; const layer = page.layer(layerId, cleanName(group.getAttribute('data-name'))); for (const element of group.querySelectorAll('g[data-stroke]')) { const points = (element.getAttribute('data-points') ?? '').split(/\s+/).map(Number); const stroke = cleanStroke({ id: element.getAttribute('data-stroke'), color: element.getAttribute('stroke'), width: Number(element.getAttribute('data-width')), points, done: true, }); if (stroke && points.length >= POINT_SIZE) layer.strokes.set(stroke.id, stroke); } for (const element of group.querySelectorAll('g[data-text]')) { const widthText = element.getAttribute('data-width') ?? ''; const heightText = element.getAttribute('data-height') ?? ''; const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number); const block = cleanText({ id: element.getAttribute('data-text'), x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')), width: widthText === '' ? null : Number(widthText), height: heightText === '' ? null : Number(heightText), size: Number(element.getAttribute('data-size')), font: element.getAttribute('data-font') ?? 'sans-serif', color: element.querySelector('text')?.getAttribute('fill'), text: element.querySelector('desc')?.textContent ?? '', lines: [...element.querySelectorAll('tspan')].map((span) => span.textContent ?? ''), boxWidth: box[0], boxHeight: box[1], }); if (block) layer.texts.set(block.id, block); } } page.recomputeStrokeBounds(); page.updateExtent(); return { page, hostId: root.getAttribute('data-host') ?? '', session: root.getAttribute('data-session') ?? '', }; }