Skip to content
This repository was archived by the owner on Oct 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/adr/411-faces-and-resources-from-the-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
76 changes: 76 additions & 0 deletions docs/adr/414-baked-faces-are-kept-with-the-document.md
Original file line number Diff line number Diff line change
@@ -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/<hash>.json`: `{ v: 1, family, style, face: <base64 bytes> }`, where
`<hash>` 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.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
7 changes: 6 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ label in the batch that creates the rectangle, so one undo removes both. Older d
also carry a `<frame id>/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/<hash>.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 |
Expand All @@ -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 |
Expand Down
6 changes: 5 additions & 1 deletion docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>.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
Expand Down
2 changes: 1 addition & 1 deletion packages/web-bundle/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/web-bundle/src/activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 6 additions & 3 deletions packages/web-bundle/src/bake-to-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)],
};
}

Expand Down Expand Up @@ -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)],
};
}
108 changes: 104 additions & 4 deletions packages/web-bundle/src/document-faces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>.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" });

Expand All @@ -54,6 +63,13 @@ async function documentHolds(host: BundleHost, family: string): Promise<boolean
}
}

/** What {@link provideDocumentFaces} did: the problems, and the faces the
* document took (to keep with it once the bake applied). */
export interface ProvidedFaces {
diagnostics: WebDiagnostic[];
given: KeptFace[];
}

/** Give the document a face for every family in `families` it holds none
* for, from the faces the bundle shaped with. Answers the problems: a family
* the bundle holds no face for (or a host without the door), or a face the
Expand All @@ -62,8 +78,9 @@ export async function provideDocumentFaces(
host: BundleHost,
engine: WebEngine,
families: Iterable<string>,
): Promise<WebDiagnostic[]> {
): Promise<ProvidedFaces> {
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;
Expand All @@ -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<WebDiagnostic[]> {
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<WebDiagnostic[]> {
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<string>;
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<void> = 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 };
}
Loading
Loading