322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
// @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.
|
|
*
|
|
* <svg data-kokkoja="1" data-session=".." data-page=".." data-number="3"
|
|
* viewBox="0 0 2100 2970" ...>
|
|
* <rect .../> the paper
|
|
* <image data-hash=".." href="data:image/png;base64,..."/> one for each image
|
|
* <g data-layer="<participant id>" data-name="Andy">
|
|
* <g data-stroke="<stroke id>" data-width="7" data-points="x y p ..."
|
|
* stroke="#1f4fd8" ...>
|
|
* <path d="M... Q..." stroke-width="6.9"/> what is shown
|
|
* </g>
|
|
* <g data-text="<text id>" data-x=".." data-y=".." data-width=".." ...>
|
|
* <desc>the text as typed</desc>
|
|
* <text ...><tspan>one line as shown</tspan>...</text>
|
|
* </g>
|
|
* </g>
|
|
* </svg>
|
|
*
|
|
* 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<Uint8Array, string>}
|
|
*/
|
|
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 `<image data-hash="${image.hash}"${image.by ? ` data-by="${image.by}"` : ''} x="${image.x}" y="${image.y}"` +
|
|
` width="${image.width}" height="${image.height}"` +
|
|
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
|
|
}
|
|
|
|
/**
|
|
* @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, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @returns {string} The text, safe as XML content.
|
|
*/
|
|
function escapedContent(text) {
|
|
return text.replace(/&/g, '&').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) =>
|
|
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
|
|
` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
|
|
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
|
|
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
|
|
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
|
|
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
|
|
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
|
|
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
|
|
` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
|
|
` data-size="${block.size}" data-font="${block.font}"` +
|
|
` data-box="${block.boxWidth} ${block.boxHeight}">` +
|
|
`<desc>${escapedContent(block.text)}</desc>${frame}` +
|
|
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
|
|
` xml:space="preserve">${lines.join('')}</text></g>`;
|
|
}
|
|
|
|
/**
|
|
* @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 `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
|
|
` fill="${stroke.color}" stroke="none"/>`;
|
|
}
|
|
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 d="${path}" stroke-width="${pathWidth}"/>`;
|
|
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 + `<path d="${path}" stroke-width="${pathWidth}"/>`;
|
|
}
|
|
|
|
/**
|
|
* @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<string>} [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 = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
`<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` +
|
|
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
|
|
` data-page="${escaped(page.id)}" data-number="${page.number}"` +
|
|
` data-base="${page.baseWidth} ${page.baseHeight}` +
|
|
`${page.baseLeft || page.baseTop ? ` ${page.baseLeft} ${page.baseTop}` : ''}"` +
|
|
` data-revision="${page.imagesRevision}"` +
|
|
` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` +
|
|
` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
|
|
`<title>Kokkoja page ${page.number}</title>`,
|
|
`<rect x="${page.left}" y="${page.top}" width="${page.width}" height="${page.height}"` +
|
|
' fill="#ffffff"/>',
|
|
];
|
|
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(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
|
|
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
|
|
}
|
|
}
|
|
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
|
|
for (const id of page.layerOrder(hostId)) {
|
|
const layer = page.layers.get(id);
|
|
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
|
|
lines.push(`<g data-layer="${escaped(layer.id)}" data-name="${escaped(layer.name)}">`);
|
|
for (const stroke of layer.strokes.values()) {
|
|
if (pointCount(stroke) === 0) continue;
|
|
lines.push(
|
|
`<g data-stroke="${escaped(stroke.id)}" data-width="${stroke.width}"` +
|
|
` data-points="${stroke.points.join(' ')}" stroke="${stroke.color}" fill="none"` +
|
|
` stroke-linecap="round" stroke-linejoin="round">${strokeShapes(stroke)}</g>`);
|
|
}
|
|
for (const block of layer.texts.values()) lines.push(textElements(block));
|
|
lines.push('</g>');
|
|
}
|
|
// Hide rectangles: white, above all writing.
|
|
if (page.hides.length) {
|
|
lines.push('<g data-hides="">');
|
|
for (const hide of page.hides) {
|
|
lines.push(`<rect data-hide="${escaped(hide.id)}" x="${hide.x}" y="${hide.y}"` +
|
|
` width="${hide.width}" height="${hide.height}" fill="#ffffff"/>`);
|
|
}
|
|
lines.push('</g>');
|
|
}
|
|
lines.push('</svg>', '');
|
|
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<string, Uint8Array<ArrayBuffer>>} 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') ?? '',
|
|
};
|
|
}
|