// @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.
*
*
*
* 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 = [
'',
`', '');
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') ?? '',
};
}