From fa1c30311bce0c23ff86f0c4075ee77f876621fc Mon Sep 17 00:00:00 2001 From: Dietmar Rietsch Date: Thu, 15 Oct 2026 13:26:16 +0200 Subject: [PATCH 1/2] test: the faces a bake gave the document come back on open A document face lives as long as the document it was given to (plugin SDK ADR 330; on an older host, the engine session) and is never saved with it, while the baked text naming it is. paged.web gives its faces only when it bakes, so a reopened document draws its baked text in a substitute and reports the family missing. Against the real host and engine, opening the saved .paged in a new session: the bundled face of a baked run comes back without a re-bake; an @font-face face comes back after the web frame and its source are deleted; a face no text names after an undone bake is not given back, and its record is dropped on the next save. All three fail on main. Co-Authored-By: Claude Opus 5.5 --- .../conformance/baked-faces-reopen.spec.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/web-bundle/test/conformance/baked-faces-reopen.spec.ts diff --git a/packages/web-bundle/test/conformance/baked-faces-reopen.spec.ts b/packages/web-bundle/test/conformance/baked-faces-reopen.spec.ts new file mode 100644 index 0000000..ff90556 --- /dev/null +++ b/packages/web-bundle/test/conformance/baked-faces-reopen.spec.ts @@ -0,0 +1,173 @@ +/* + * This file is part of paged (https://paged.media). + * + * paged is free software: you may redistribute it and/or modify it under the + * terms of the GNU Affero General Public License, version 3, as published by + * the Free Software Foundation, OR under the Paged Media Enterprise License + * (PMEL), a commercial license available from And The Next GmbH. Full + * copyright and license information is available in LICENSE.md, distributed + * with this source code. + * + * paged is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the licenses for details. + * + * @copyright Copyright (c) And The Next GmbH + * @license AGPL-3.0-only OR Paged Media Enterprise License (PMEL) + */ + +// The faces a bake gave the document come back when the document is opened +// again, without a re-bake (ADR 414). A document face lives as long as the +// document it was given to (plugin SDK ADR 330) — and never longer than the +// engine session — while the baked text naming it is saved with the +// document. Against the real host and the real Blitz engine: bake, save as +// .paged, open the file in a NEW session (a fresh headless host, whose engine +// has never seen the face), activate the bundle, and the family is not +// missing. The strongest case is a baked frame that is deleted after the bake: +// its source — the only thing that says which face its text was set in — is +// gone, and the face still comes back. + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { BundleHost, ElementId } from "@paged-media/plugin-api"; +import { createHeadlessHost, type HeadlessHost } from "@paged-media/plugin-sdk"; +import { DEFAULT_SOURCE, type WebFrameSource } from "@paged-media/web-model"; + +import { webBundle } from "../../src"; +import { bakeWebFrameToDocument } from "../../src/bake-to-document"; +import { BUNDLED_FAMILY } from "../../src/engine-inputs"; +import { writeWebSource } from "../../src/source-part"; +import { W1_EMPTY_PAGE } from "../fixtures/corpus"; +import { renamedFace } from "../fixtures/fonts"; +import { blitzPresent, primeBlitz, requireBlitz } from "./blitz"; +import { mapBacking, silent } from "./host"; + +requireBlitz("web conformance — baked faces after a reopen"); + +type Reply = { kind: string; payload?: any }; +type Send = (m: { kind: string; payload?: unknown }) => Promise; +type FontRow = { family: string; isMissing: boolean }; + +/** Shaped in the bundled face: the CSS names a family no document holds. */ +const MAILER: WebFrameSource = { + ...DEFAULT_SOURCE, + html: "

Spring line sheet

", + css: 'p { font: 18px/1.4 "IBM Plex Sans", sans-serif; margin: 0; }', +}; + +/** A face a source's own `@font-face` rule loads from the container. */ +const BRANDED: WebFrameSource = { + ...DEFAULT_SOURCE, + html: "

Branded

", + css: "@font-face{font-family:'Brand';src:url(fonts/brand.ttf) format('truetype')} body{margin:0} p{font-family:'Brand';margin:0}", +}; + +const clientOf = (h: HeadlessHost) => (h.host.editor as unknown as { client: { send: Send } }).client; + +/** A headless host wired like the editor: document face bytes come from the + * engine's document registry, scene faces go to its scene-layer registry. + * `sent` records every document-font registration. */ +async function editorLikeHost(sent: Reply[]): Promise { + const h: HeadlessHost = await createHeadlessHost({ + console: silent, + storage: mapBacking(), + assetSource: { + getFontFace: async (family: string, style?: string) => { + const r = await clientOf(h).send({ kind: "requestFontFaceBytes", payload: { family, style: style ?? null } }); + if (r.kind !== "fontFaceBytes" || !r.payload.found) return null; + return { bytes: Uint8Array.from(r.payload.bytes), format: "truetype", family: r.payload.family }; + }, + registerFont: async (family: string, bytes: Uint8Array, style?: string) => { + await clientOf(h).send({ kind: "registerFont", payload: { family, style: style ?? null, bytes: [...bytes], scope: "sceneLayer" } }); + }, + clearSceneFonts: async () => { + await clientOf(h).send({ kind: "clearFontRegistry", payload: { scope: "sceneLayer" } }); + }, + }, + }); + const client = clientOf(h); + const raw = client.send.bind(client); + client.send = (m) => { + if (m.kind === "registerFont" && (m.payload as { scope?: string }).scope === "document") sent.push(m as Reply); + return raw(m); + }; + return h; +} + +describe.skipIf(!blitzPresent)("web conformance — the faces a bake gave the document come back on open @feat:plugin-web.bake-to-native", () => { + const hosts: HeadlessHost[] = []; + afterEach(() => { + for (const h of hosts.splice(0)) h.dispose(); + }); + + /** Bake `source` into a fresh document, optionally delete the web frame, + * and save the document as .paged. */ + async function bakeAndSave(source: WebFrameSource, options: { deleteFrame?: boolean; undoBake?: boolean } = {}) { + const sent: Reply[] = []; + const h = await editorLikeHost(sent); + hosts.push(h); + await h.load(W1_EMPTY_PAGE.bytes()); + const engine = await primeBlitz(h.host as unknown as BundleHost); + h.loadBundle(webBundle); + const host = h.host as unknown as BundleHost; + await host.parts.write("resources/fonts/brand.ttf", renamedFace("Brand")); + const out = await host.document.mutate({ + op: "insertFrame", + args: { pageId: W1_EMPTY_PAGE.pageId, bounds: [40, 40, 260, 400] }, + } as never); + if (!out.applied || !out.createdId) throw new Error("no frame"); + const frame = out.createdId as ElementId; + const w = await writeWebSource(host, frame, source); + if (!w.applied) throw new Error(`not written: ${w.reason}`); + const baked = await bakeWebFrameToDocument(host, frame, engine); + expect(baked.baked).toBe(true); + expect(sent.length, "the bake gave the document a face").toBeGreaterThan(0); + if (options.undoBake) await host.document.undo(); + if (options.deleteFrame) { + const del = await host.document.mutate({ op: "deleteFrame", args: { frameId: (frame as { id: string }).id } } as never); + expect(del.applied).toBe(true); + } + const { bytes } = await h.exportDocument("paged"); + return bytes; + } + + /** Open `bytes` in a NEW session and activate the bundle. */ + async function reopen(bytes: Uint8Array) { + const sent: Reply[] = []; + const h = await editorLikeHost(sent); + hosts.push(h); + await h.load(bytes); + h.loadBundle(webBundle); + const host = h.host as unknown as BundleHost; + const row = async (family: string) => + (await host.document.collection("fonts")).find((f) => f.family === family); + return { h, host, sent, row }; + } + + it("the bundled face of a baked run is given back on open, without a re-bake", async () => { + const saved = await bakeAndSave(MAILER); + const { row, sent } = await reopen(saved); + expect(await row(BUNDLED_FAMILY), "the baked text names the bundled face").toBeTruthy(); + await vi.waitFor(async () => expect((await row(BUNDLED_FAMILY))?.isMissing).toBe(false), { timeout: 3_000 }); + expect(sent.map((m) => m.payload.family)).toEqual([BUNDLED_FAMILY]); + }); + + it("an @font-face face comes back after the web frame and its source are deleted", async () => { + const saved = await bakeAndSave(BRANDED, { deleteFrame: true }); + const { row } = await reopen(saved); + expect((await row("Brand"))?.isMissing === undefined, "the baked text still names Brand").toBe(false); + await vi.waitFor(async () => expect((await row("Brand"))?.isMissing).toBe(false), { timeout: 3_000 }); + }); + + it("a face no text names any more is not given back, and its record is dropped on the next save", async () => { + const saved = await bakeAndSave(MAILER, { undoBake: true }); + const { h, host, sent, row } = await reopen(saved); + // Give the open pass time to run; nothing names the family, so nothing is given. + await new Promise((r) => setTimeout(r, 200)); + expect(await row(BUNDLED_FAMILY)).toBeUndefined(); + expect(sent).toEqual([]); + expect((await host.parts.list("faces/")).length, "the bake left its record").toBeGreaterThan(0); + await h.willSave.fire(); + expect(await host.parts.list("faces/")).toEqual([]); + }); +}); From 68a5a680b72ac4e11a5a7172401db90b7958854f Mon Sep 17 00:00:00 2001 From: Dietmar Rietsch Date: Thu, 15 Oct 2026 13:26:16 +0200 Subject: [PATCH 2/2] web-bundle: keep the faces a bake gives the document, give them back on open; 0.1.0-canary.17 A face given through the document-face door lives as long as the document (plugin SDK ADR 330) and is not saved with it; the baked text naming it is. Working the faces out again on open is not correct: without a record the plugin cannot tell its baked text from other text naming the same family (it would give the bundled Inter to a document whose own Inter is missing), and once the web frame is deleted after the bake its source, the only thing naming an @font-face face, is gone. So the bake keeps each face it gave, once the bake applied, in a content-addressed part faces/.json ({ v, family, style, face } with the bytes in base64; never overwritten, written once). Each time a document opens, and at activation, the faces its text names and lacks (the Fonts collection) are given back through the same door: no engine, no re-bake, and no part read when nothing is kept or missing. A face the document refuses is a problem under media.paged.web#faces. The source-part collector drops a kept face no text names on save (after an undone bake, or deleted text), as it drops unreachable sources. ADR 414, an amendment to ADR 411, status and architecture. @paged-media/web 0.1.0-canary.17. Co-Authored-By: Claude Opus 5.5 --- ...1-faces-and-resources-from-the-document.md | 8 ++ ...-baked-faces-are-kept-with-the-document.md | 76 ++++++++++++ docs/adr/README.md | 3 +- docs/architecture.md | 7 +- docs/status.md | 6 +- packages/web-bundle/package.json | 2 +- packages/web-bundle/src/activate.ts | 4 + packages/web-bundle/src/bake-to-document.ts | 9 +- packages/web-bundle/src/document-faces.ts | 108 +++++++++++++++++- packages/web-bundle/src/parts-gc.ts | 45 +++++++- packages/web-bundle/src/source-part.ts | 108 ++++++++++++++++++ 11 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 docs/adr/414-baked-faces-are-kept-with-the-document.md diff --git a/docs/adr/411-faces-and-resources-from-the-document.md b/docs/adr/411-faces-and-resources-from-the-document.md index baaaa38..e56bade 100644 --- a/docs/adr/411-faces-and-resources-from-the-document.md +++ b/docs/adr/411-faces-and-resources-from-the-document.md @@ -133,3 +133,11 @@ the document its face on a host whose `host.editor` throws), `test/auto-render.s gesture commit and a script run reported by the door re-render; the raw broadcast is not listened to), `test/conformance/auto-render.spec.ts` (a resize gesture on the real engine, through the headless client's broadcast alone). + +## Amendment 2026-10-15 + +A face given to the document lives as long as that document (plugin SDK ADR 330), and was never +saved with it, while the baked text naming it is. The bake now keeps every face it gave in a +content-addressed container part, and the faces a document's text names and lacks are given back +from those parts each time it opens, with no engine and no re-bake, also when the web frame is +gone. See [ADR 414](414-baked-faces-are-kept-with-the-document.md). diff --git a/docs/adr/414-baked-faces-are-kept-with-the-document.md b/docs/adr/414-baked-faces-are-kept-with-the-document.md new file mode 100644 index 0000000..a1402da --- /dev/null +++ b/docs/adr/414-baked-faces-are-kept-with-the-document.md @@ -0,0 +1,76 @@ +# ADR 414 — The faces a bake gives the document are kept with it and given back on open + +- **Status:** Accepted, 2026-10-15. +- **Scope:** `packages/web-bundle/src/document-faces.ts` (`keepDocumentFaces`, + `giveBackDocumentFaces`, `startDocumentFaces`), `source-part.ts` (the `faces/` parts), + `parts-gc.ts` (their collection), `bake-to-document.ts`, `activate.ts`. Amends + [ADR 411](411-faces-and-resources-from-the-document.md) (its amendments of 2026-10-12 and + 2026-10-14). + +## Context + +A bake gives the document the faces its runs were shaped in and the document holds none for: +the bundled Inter, or a face a source's `@font-face` rule loads (ADR 411). It gives them through +the plugin API's document-face door, and a face given that way is never saved with the document. +A document names its fonts and carries no face bytes. The baked text that names the face is +saved, though. + +How long the face lived used to hide this. The engine kept a face for the session, so a document +reopened in the same session still had it, and the gap showed only in a new session. Since plugin +SDK ADR 330 (`assets.documentFont@2`) a face lives as long as the document it was given to. Every +reopen, the same session included, now shows the baked text as missing and draws it in a +substitute. paged.web gave its faces only when it baked, never when a document opened. + +Two ways to give them back: + +- **Work them out again from what the document keeps.** The bundled face is in the engine; an + `@font-face` face is in a source's rule, behind a container part or a `data:` URI. This fails + in two cases. Without a record, the plugin cannot tell its baked text from any other text + naming the same family, so it would give the bundled Inter to a document whose own Inter is + missing (the rule plugin SDK DESIGN.md §13.6 exists for). And once the web frame is deleted + after the bake, a common way to flatten, the source and its rules are gone. A `data:` face is + then gone with them, and the source part is dropped on the next save (ADR 410). It also loads + the engine (about 10 MB of wasm) on every open of a document that only holds baked text. +- **Keep a record of each face the bake gave.** This is correct in every case above: it survives + deleting the frame, it names exactly the faces the bake gave, and giving them back needs no + engine. It costs the face bytes in the document. + +## Decision + +- **The bake keeps each face it gave**, once the bake has applied, in a content-addressed + container part `faces/.json`: `{ v: 1, family, style, face: }`, where + `` is the content hash of that text, as for sources. A part is never overwritten; one + already kept is not written again. A face the door refused is not kept. A face that could not + be kept is reported as a problem. +- **Each time a document opens**, and for the document open when the bundle activates, the bundle + lists the `faces/` parts. If there are any, it reads the Fonts collection. A kept face whose + family the document's text names and lacks is given back through the same door. Nothing is + read when the document carries no kept face or misses no family. A face the document refuses + is a problem under `media.paged.web#faces`. +- **A record no text names is dropped on save.** The collector (ADR 410) marks a `faces/` part + at open when no row of the Fonts collection names its family, and deletes it on save if that + is still true and the session did not write it. That happens after an undone bake, or once the + baked text is deleted. +- **Base64 in JSON, one part per face.** One self-describing part keeps the reader, the + collector and the content addressing the same as for sources. The cost is a third more bytes + before the container's compression. A face is kept once per document whatever the number of + bakes; the bundled Inter is about 0.9 MB. + +## Consequences + +- A document with baked text reopens with its faces, in a new session as in the same one, with + or without the web frame it was baked from, and on a host that scopes faces to the document as + on one that keeps them for the session (where giving one back answers `held`). +- A document that bakes carries the faces it baked with. The bundled face is OFL. An + `@font-face` face was already in the document, as a container part or inside the source. +- The bundle reads the Fonts collection on each open of a document that keeps faces. +- Plugin SDK ADR 330 says the faces must be given after the document opens. The open pass runs + from `document.onDidOpen`, which fires after the engine holds the document. + +## Related + +- [ADR 411](411-faces-and-resources-from-the-document.md): the faces a bake gives. +- [ADR 410](410-unreachable-source-parts-dropped-on-save.md): the collector this widens. +- [ADR 409](409-label-is-the-truth-large-sources-by-pointer.md): the content-addressed parts it + follows. +- Plugin SDK ADR 330: a document face lives as long as its document. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7ae9cc0..a90ec47 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,9 +25,10 @@ the code as it stood, for decisions made earlier; their status says so. | [408](408-no-page-javascript.md) | Page JavaScript never executes; templating is a closed pass | Accepted, recorded retroactively 2026-10-02 | | [409](409-label-is-the-truth-large-sources-by-pointer.md) | The label names the live source; a large source is a content-addressed part behind a pointer | Accepted 2026-10-05 | | [410](410-unreachable-source-parts-dropped-on-save.md) | Source parts no label or undo step can reach are dropped on save | Accepted 2026-10-05 | -| [411](411-faces-and-resources-from-the-document.md) | A render takes its faces and resources from the document, never the network | Accepted 2026-10-05 | +| [411](411-faces-and-resources-from-the-document.md) | A render takes its faces and resources from the document, never the network | Accepted 2026-10-05; amended by 414 | | [412](412-a-frame-is-a-page-box.md) | A frame is a page box; fragmentation rules are read from the source CSS | Accepted 2026-10-05 | | [413](413-object-model-and-document-values-in-the-document-label.md) | Web frames, bound values and CSS variables are objects; document values live in the document label | Accepted 2026-10-06 | +| [414](414-baked-faces-are-kept-with-the-document.md) | The faces a bake gives the document are kept with it and given back on open | Accepted 2026-10-15 | Decisions made in other repositories that this plugin's code rests on are listed in [`../README.md`](../README.md). diff --git a/docs/architecture.md b/docs/architecture.md index 7901426..8c19866 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,6 +171,11 @@ label in the batch that creates the rectangle, so one undo removes both. Older d also carry a `/source.json` part, read only when the label cannot hold its content. See [ADR 409](adr/409-label-is-the-truth-large-sources-by-pointer.md). +The faces a bake gives the document are kept beside the sources, one content-addressed part +per face, `faces/.json` (`{ v: 1, family, style, face }`, the bytes in base64), and given +back on every open to the families the document's text names and lacks (`document-faces.ts`; +[ADR 414](adr/414-baked-faces-are-kept-with-the-document.md)). + ## Host doors | Door | What the plugin uses it for | @@ -180,7 +185,7 @@ See [ADR 409](adr/409-label-is-the-truth-large-sources-by-pointer.md). | `host.contribute.importer` | `.html` and `.htm` files become the source of a new web frame | | `host.contribute.sceneLayer()` | submit one layer per frame | | `host.document.mutate` | insert a frame with its metadata in one batch; every flatten operation | -| `host.document.getMetadata` / `setMetadata`, `host.parts.read` / `write` | the source label, and the content-addressed part of a large source | +| `host.document.getMetadata` / `setMetadata`, `host.parts.read` / `write` / `list` | the source label, the content-addressed part of a large source, and the kept faces of a bake | | `host.document.elementGeometry`, `meta`, `collection(...)` | frame bounds and page; active page for insert; new story of a flattened text frame; registered font families | | `host.document.onDidChange`, `host.selection.get` / `set` / `onDidChange` | the panel follows the selection and re-reads after undo or redo; the auto renderer and the preview size follow every committed change, a resize by the frame's handles (a gesture commit) and a script run included; commands act on the selection | | `host.diagnostics.set` | lint, render and flow findings | diff --git a/docs/status.md b/docs/status.md index 90c8fb0..6b99a23 100644 --- a/docs/status.md +++ b/docs/status.md @@ -104,7 +104,11 @@ and the performance reading are in [`design/analysis-2026-10-05.md`](design/anal face) is given to the document first, from the bytes the engine shaped with, through the plugin API's document-face door (`host.assets.registerDocumentFont`), so the baked text is drawn in its face and is not flagged missing ([ADR 411](adr/411-faces-and-resources-from-the-document.md), - amendments 2026-10-12 and 2026-10-14). A box + amendments 2026-10-12 and 2026-10-14). The bake keeps each face it gave in a + content-addressed part (`faces/.json`), and every time the document opens the faces its + text names and lacks are given back from there, with no engine and no re-bake, also once the + web frame is deleted; a kept face no text names is dropped on save + ([ADR 414](adr/414-baked-faces-are-kept-with-the-document.md)). A box with one `border-radius` becomes a rectangle with rounded corners of that radius; a box with unequal corners stays a path. Strokes, item opacity for translucent paint and box shadows (as the native drop shadow of the rectangle or path that casts them) are kept; it diff --git a/packages/web-bundle/package.json b/packages/web-bundle/package.json index fe018d1..95d6224 100644 --- a/packages/web-bundle/package.json +++ b/packages/web-bundle/package.json @@ -1,7 +1,7 @@ { "name": "@paged-media/web", "private": false, - "version": "0.1.0-canary.16", + "version": "0.1.0-canary.17", "license": "AGPL-3.0-only OR LicenseRef-PMEL", "type": "module", "main": "./src/index.ts", diff --git a/packages/web-bundle/src/activate.ts b/packages/web-bundle/src/activate.ts index be12dd2..0d0273a 100644 --- a/packages/web-bundle/src/activate.ts +++ b/packages/web-bundle/src/activate.ts @@ -35,6 +35,7 @@ import { import manifest from "../manifest.json"; import { startAutoRender } from "./auto-render"; +import { startDocumentFaces } from "./document-faces"; import { releaseSceneFaces } from "./engine-inputs"; import { releaseOverlayLayers } from "./overlay-channel"; import { startPartsCollector } from "./parts-gc"; @@ -200,8 +201,11 @@ export function activate(host: BundleHost): BundleHandle { const auto = startAutoRender(host); // Source parts no label or undo step can reach are dropped on save. const collector = startPartsCollector(host); + // The faces a bake gave a document come back each time it opens (ADR 414). + const faces = startDocumentFaces(host); return { dispose() { + faces.dispose(); collector.dispose(); auto.dispose(); propertyLists?.dispose(); diff --git a/packages/web-bundle/src/bake-to-document.ts b/packages/web-bundle/src/bake-to-document.ts index 2aa1acd..62ae044 100644 --- a/packages/web-bundle/src/bake-to-document.ts +++ b/packages/web-bundle/src/bake-to-document.ts @@ -52,7 +52,7 @@ import { type PlacedBakePlan, type RunMetrics, } from "./bake-plan"; -import { provideDocumentFaces } from "./document-faces"; +import { keepDocumentFaces, provideDocumentFaces } from "./document-faces"; import { engineDocument } from "./engine-document"; import { prepareEngineInputs, resourceDiagnostics } from "./engine-inputs"; import { encodePng } from "./png"; @@ -168,13 +168,15 @@ export async function bakeWebFrameToDocument( const placed = await placePlan(host, plan, pageId, top, left); const applied = await applyBake(host, [placed]); if ("refused" in applied) return fail(applied.refused); + // The faces go with the document, so its text keeps them when it reopens. + const kept = applied.created > 0 ? await keepDocumentFaces(host, faces.given) : []; return { baked: applied.created > 0, createdCount: applied.created, swatchCount: applied.swatchCount, deferred: plan.deferred, - diagnostics: [...missing, ...faces, ...deferredDiagnostics(applied.created, plan.deferred)], + diagnostics: [...missing, ...faces.diagnostics, ...kept, ...deferredDiagnostics(applied.created, plan.deferred)], }; } @@ -370,12 +372,13 @@ export async function bakeWebFlowToDocument( const applied = await applyBake(host, placed); if ("refused" in applied) return fail(applied.refused); const { created, swatchCount } = applied; + const kept = created > 0 ? await keepDocumentFaces(host, faces.given) : []; return { baked: created > 0, createdCount: created, swatchCount, deferred, - diagnostics: [...faces, ...deferredDiagnostics(created, deferred)], + diagnostics: [...faces.diagnostics, ...kept, ...deferredDiagnostics(created, deferred)], }; } diff --git a/packages/web-bundle/src/document-faces.ts b/packages/web-bundle/src/document-faces.ts index 51fe900..7f5e793 100644 --- a/packages/web-bundle/src/document-faces.ts +++ b/packages/web-bundle/src/document-faces.ts @@ -33,14 +33,23 @@ // (`host.assets.registerDocumentFont`). The door fills a gap and never // replaces a face the document holds; a family the document already holds // (`assets.getFontFace` answers from the document registry) is not offered -// at all. The engine keeps a registered face for the session (every document -// opened later starts with it). +// at all. +// +// A document face lives as long as the document it was given to (plugin SDK +// ADR 330; on an older host, the engine session) and is not saved with it, +// while the baked text naming it is. So a bake keeps every face it gave the +// document in a content-addressed part (`faces/.json`, source-part.ts), +// and each time a document opens the faces its text names and lacks are given +// back from those parts (ADR 414) — with no engine and no re-bake, and also +// when the web frame and its source are gone. -import type { BundleHost } from "@paged-media/plugin-api"; +import type { BundleHost, Disposable } from "@paged-media/plugin-api"; import type { WebDiagnostic } from "../../web-model/src"; +import { onDocumentOpened } from "./document-opened"; import { heldFaces } from "./engine-inputs"; import type { WebEngine } from "./engine-loader"; +import { keepFace, keptFaceFamily, keptFacePaths, readKeptFace, type KeptFace } from "./source-part"; const problem = (message: string): WebDiagnostic => ({ severity: "warning", message, source: "render" }); @@ -54,6 +63,13 @@ async function documentHolds(host: BundleHost, family: string): Promise, -): Promise { +): Promise { const out: WebDiagnostic[] = []; + const given: KeptFace[] = []; // Each capability is probed once, and only when a family needs it. let canTell: boolean | undefined; let door: boolean | undefined; @@ -89,8 +106,91 @@ export async function provideDocumentFaces( const outcome = await host.assets.registerDocumentFont(face.bytes, family, face.style); if (outcome.status === "refused") { out.push(problem(`the document did not take the face “${family}${face.style ? ` ${face.style}` : ""}” (${outcome.reason})`)); + } else { + given.push({ family, ...(face.style ? { style: face.style } : {}), bytes: face.bytes }); } } } + return { diagnostics: out, given }; +} + +/** Keep the faces a bake gave the document with the document, so they come + * back when it opens again. Answers the problems: a face the document could + * not keep (its text is drawn in a substitute once the document reopens). */ +export async function keepDocumentFaces(host: BundleHost, given: readonly KeptFace[]): Promise { + const out: WebDiagnostic[] = []; + for (const face of given) { + if (await keepFace(host, face)) continue; + out.push( + problem( + `the face “${face.family}${face.style ? ` ${face.style}` : ""}” could not be kept with the document: ` + + "opened again, its baked text is drawn in a substitute", + ), + ); + } return out; } + +/** The problems the open pass found, under one key. */ +export const FACE_DIAG_KEY = "media.paged.web#faces"; + +/** Give the open document back the faces its bakes kept, for the families + * its text names and has no face for. Reads no part when nothing is + * missing. Answers the problems (a face the document would not take back); + * never throws. */ +export async function giveBackDocumentFaces(host: BundleHost): Promise { + const problems: WebDiagnostic[] = []; + if (!host.supports("assets.documentFont@1")) return problems; + const paths = await keptFacePaths(host); + if (paths.length === 0) return problems; + let missing: Set; + try { + const rows = await host.document.collection<{ family?: unknown; isMissing?: unknown }>("fonts"); + missing = new Set( + rows + .filter((r) => r.isMissing === true && typeof r.family === "string") + .map((r) => (r.family as string).toLowerCase()), + ); + } catch { + return problems; // no document open + } + if (missing.size === 0) return problems; + for (const path of paths) { + const family = await keptFaceFamily(host, path); + if (!family || !missing.has(family.toLowerCase())) continue; + const face = await readKeptFace(host, path); + if (!face) continue; + const outcome = await host.assets.registerDocumentFont(face.bytes, face.family, face.style); + if (outcome.status === "refused") { + problems.push( + problem( + `the document did not take back the face “${face.family}${face.style ? ` ${face.style}` : ""}” ` + + `its baked text is set in (${outcome.reason}): that text is drawn in a substitute`, + ), + ); + } + } + return problems; +} + +/** Give the faces back for the document open at activation and every + * document opened after; the problems of the open document are published + * under {@link FACE_DIAG_KEY}. */ +export function startDocumentFaces(host: BundleHost): Disposable { + let pass: Promise = Promise.resolve(); + let reported = false; + const run = () => { + pass = pass + .then(async () => { + const problems = await giveBackDocumentFaces(host); + if (problems.length > 0 || reported) host.diagnostics.set(FACE_DIAG_KEY, problems); + reported = problems.length > 0; + }) + .catch((err: unknown) => { + host.log.warn(`faces of the opened document: ${String(err)}`); + }); + }; + const off = onDocumentOpened(host, run); + run(); + return { dispose: off }; +} diff --git a/packages/web-bundle/src/parts-gc.ts b/packages/web-bundle/src/parts-gc.ts index 983122c..766da56 100644 --- a/packages/web-bundle/src/parts-gc.ts +++ b/packages/web-bundle/src/parts-gc.ts @@ -34,6 +34,10 @@ // Parts written this session are never dropped; the next open collects // them. Deleting needs `storage.parts@2`; without it nothing is removed. // +// A kept face (`faces/.json`, ADR 414) is garbage once no text in the +// document names its family — after the bake that kept it was undone, or its +// text deleted — at the same two moments. +// // The old document-values part (`web/document-values.json`) follows the // same two moments: it is garbage once the document metadata carries the // value map (source-part.ts `documentValuesMigrated`) both when the document @@ -45,7 +49,13 @@ import { sourceRefOf, type WebSourceEnvelope } from "../../web-model/src"; import { discoverWebFrames } from "./auto-render"; import { onDocumentOpened } from "./document-opened"; -import { DOCUMENT_VALUES_PART, documentValuesMigrated, partsWrittenThisSession } from "./source-part"; +import { + DOCUMENT_VALUES_PART, + documentValuesMigrated, + FACE_PART, + keptFaceFamily, + partsWrittenThisSession, +} from "./source-part"; const SOURCE_PART = /^sources\/([0-9a-f]{16})\.json$/; const LEGACY_PART = /^([^/]+)\/source\.json$/; @@ -55,9 +65,27 @@ interface Reach { items: Set; /** The document metadata carries the value map: the old part is unread. */ valuesMigrated: boolean; + /** Each kept face among the parts asked about: whether the document's + * text names its family (an unreadable part names nothing). */ + facesNamed: Map; +} + +/** Whether the document's text names the family of each kept face in + * `paths` (the Fonts collection: every family a run or style names). */ +async function facesNamed(host: BundleHost, paths: readonly string[]): Promise> { + const out = new Map(); + const faces = paths.filter((p) => FACE_PART.test(p)); + if (faces.length === 0) return out; + const rows = await host.document.collection<{ family?: unknown }>("fonts"); + const named = new Set(rows.map((r) => (typeof r.family === "string" ? r.family.toLowerCase() : ""))); + for (const path of faces) { + const family = await keptFaceFamily(host, path); + out.set(path, family !== null && named.has(family.toLowerCase())); + } + return out; } -async function reachable(host: BundleHost): Promise { +async function reachable(host: BundleHost, paths: readonly string[] = []): Promise { const { items, found } = await discoverWebFrames(host); const hashes = new Set(); for (const f of found) { @@ -69,11 +97,17 @@ async function reachable(host: BundleHost): Promise { } } const valuesMigrated = await documentValuesMigrated(host).catch(() => false); - return { hashes, items: new Set(items.map((i) => (i as { id: string }).id)), valuesMigrated }; + return { + hashes, + items: new Set(items.map((i) => (i as { id: string }).id)), + valuesMigrated, + facesNamed: await facesNamed(host, paths), + }; } function unreachable(path: string, reach: Reach): boolean { if (path === DOCUMENT_VALUES_PART) return reach.valuesMigrated; + if (FACE_PART.test(path)) return reach.facesNamed.get(path) === false; const content = SOURCE_PART.exec(path); if (content) return !reach.hashes.has(content[1]); const legacy = LEGACY_PART.exec(path); @@ -88,7 +122,8 @@ export function startPartsCollector(host: BundleHost): Disposable { garbage = (async () => { if (!host.supports("storage.parts@1")) return []; try { - const [reach, paths] = await Promise.all([reachable(host), host.parts.list("")]); + const paths = await host.parts.list(""); + const reach = await reachable(host, paths); return paths.filter((p) => unreachable(p, reach)); } catch { return []; // no document yet, or no parts: nothing to collect @@ -99,7 +134,7 @@ export function startPartsCollector(host: BundleHost): Disposable { const willSave = host.document.onWillSave(async () => { const marked = await garbage; if (marked.length === 0 || !host.supports("storage.parts@2")) return; - const reach = await reachable(host); + const reach = await reachable(host, marked); const written = partsWrittenThisSession(host); const dropped: string[] = []; for (const p of marked) { diff --git a/packages/web-bundle/src/source-part.ts b/packages/web-bundle/src/source-part.ts index 4b289ab..4716b3d 100644 --- a/packages/web-bundle/src/source-part.ts +++ b/packages/web-bundle/src/source-part.ts @@ -55,6 +55,7 @@ import type { PluginMetadataEnvelope, } from "@paged-media/plugin-api"; import { + contentHash, isWebFrameEnvelope, LABEL_INLINE_MAX_BYTES, sourceFromEnvelope, @@ -420,3 +421,110 @@ export async function writeResourcePart( return false; } } + +// ------------------------------------------------------- baked faces + +/** Where the faces a bake gave the document are kept (ADR 414): one + * content-addressed part per face, `faces/.json`, holding its family, + * its style and its bytes. A document face lives as long as the document it + * was given to and is never saved with it, while the baked text naming it + * is; the part is what gives it back when the document opens again. Never + * overwritten (the path is the hash of the text); the ones no text names + * are dropped on save by parts-gc.ts. */ +export const FACE_PREFIX = "faces/"; +export const FACE_PART = /^faces\/([0-9a-f]{16})\.json$/; + +/** A face a bake gave the document. `style` is absent for a face without + * one (the bundled face, a weight range). */ +export interface KeptFace { + family: string; + style?: string; + bytes: Uint8Array; +} + +function toBase64(bytes: Uint8Array): string { + let s = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(s); +} + +function fromBase64(text: string): Uint8Array { + const bin = atob(text); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i); + return out; +} + +/** The family and style of every face part read, by path: the parts are + * content-addressed, so what a path holds never changes. */ +const faceHeads = new WeakMap>(); + +function faceHeadCache(host: object): Map { + let cache = faceHeads.get(host); + if (!cache) { + cache = new Map(); + faceHeads.set(host, cache); + } + return cache; +} + +/** Keep a face the bake gave the document. Writes nothing when the document + * already keeps it. Answers whether the document keeps it now. */ +export async function keepFace(host: Pick, face: KeptFace): Promise { + if (!host.supports("storage.parts@1")) return false; + const text = JSON.stringify({ v: 1, family: face.family, style: face.style ?? null, face: toBase64(face.bytes) }); + const path = `${FACE_PREFIX}${contentHash(text)}.json`; + // Kept this session whether written now or before: the collector leaves it. + partsWrittenThisSession(host).add(path); + try { + if ((await host.parts.list(path)).includes(path)) return true; + await host.parts.write(path, new TextEncoder().encode(text)); + faceHeadCache(host).set(path, { family: face.family, ...(face.style ? { style: face.style } : {}) }); + return true; + } catch { + return false; + } +} + +/** The paths of the face parts the document carries. */ +export async function keptFacePaths(host: Pick): Promise { + if (!host.supports("storage.parts@1")) return []; + try { + return (await host.parts.list(FACE_PREFIX)).filter((p) => FACE_PART.test(p)); + } catch { + return []; + } +} + +/** A kept face, or `null` when the part is gone or unreadable. */ +export async function readKeptFace( + host: Pick, + path: string, +): Promise { + if (!host.supports("storage.parts@1")) return null; + try { + const bytes = await host.parts.read(path); + if (!bytes) return null; + const rec = JSON.parse(decoder.decode(bytes)) as { v?: unknown; family?: unknown; style?: unknown; face?: unknown }; + if (rec.v !== 1 || typeof rec.family !== "string" || !rec.family || typeof rec.face !== "string") return null; + const style = typeof rec.style === "string" && rec.style ? rec.style : undefined; + faceHeadCache(host).set(path, { family: rec.family, ...(style ? { style } : {}) }); + return { family: rec.family, ...(style ? { style } : {}), bytes: fromBase64(rec.face) }; + } catch { + return null; + } +} + +/** The family a face part keeps a face for (read once per path), or `null` + * when the part is unreadable. */ +export async function keptFaceFamily( + host: Pick, + path: string, +): Promise { + const cached = faceHeadCache(host).get(path); + if (cached) return cached.family; + return (await readKeptFace(host, path))?.family ?? null; +} +