oficina / docs Public
Plan 7 — images, links and tables: what a pasted document keeps
Brief, from Paul, 2026-09-02: "make a plan to handle images, links, and tables. Images can be rendered immediately and uploaded async to object storage" — after pasting a Google Doc and finding the table flattened and the formatting gone.
Spec §11.2 already commits to all three: "The schema covers headings and named styles, lists, tables, images, links, headers and footers, page setup and comments." So none of this is a new decision about scope. What it is, is one decision about a safety property that two of the three depend on, and a storage seam the app does not yet have.
Revision 2, after Fable's review. It changed two decisions — D4's ordering and D6's expectation — and corrected several facts, the largest being that revision 1 proposed writing an escaper that already exists.
What is actually broken today
Verified against origin/main by running Google-Docs clipboard HTML through the shipped schema and the real wazero core.
Tables are not in the schema at all. A pasted table becomes one top-level paragraph per cell, in row-major order, the grid gone. The When/What/Who table in Paul's rollout doc came out as six consecutive paragraphs. core/src/fragment.rs, internal/export/markdown.go and internal/export/pdf.go know nothing about tables either.
Links and images are half-present, which is worse than absent. link and image come from prosemirror-schema-basic, so they are in the browser schema. A paste writes them into the CRDT. Then fragment.rs drops them: link is not in MARKS (fragment.rs:77-87), so the read view renders the anchor text with no anchor, and image hits the unknown-element arm (fragment.rs:203-204) and renders as nothing at all. Both exporters are equally blind. This is the module comment's own "silence here is data loss", happening now.
D1 — the safety property, and what it costs to spend it
fragment_html's output is returned as template.HTML and written unescaped by views/show.html. It is safe today because of a property the module states and maintains: no byte from a document reaches attribute position. Marks are attribute-free and render as fixed literals; paragraph.align is only ever a key into a closed table whose right-hand side is a literal; heading.level is parsed as an integer and clamped.
The escaper already exists — fragment.rs:25 escapes &, <, >, " and ', ampersand first. What is missing is everything about which values are allowed to reach an attribute at all.
Tables do not spend the property. colspan and rowspan are integers, clamped exactly as heading.level already is.
Links and images do. So this plan buys it back deliberately:
1. A positive scheme grammar, not a denylist. A rejected-characters rule is the right instinct and the wrong shape: browsers strip ASCII tab and newline anywhere in a URL and strip leading C0 controls, but not U+00A0 or U+2028, and a value with no colon at all is a third case. One rule covers all of them — the value must match ^[A-Za-z][A-Za-z0-9+.-]*:, and that scheme, case-folded, must be one of http, https, mailto. Everything else renders as text: no colon, //evil, data:, blob:, java\tscript:, \u{0}javascript:. blob: matters more than it looks — it is what this very feature manipulates in the browser, and it must never reach the CRDT.
2. src is matched against its literal shape, not merely "app-relative". //evil.example/x.png begins with /. The rule is ^/d/[A-Za-z0-9]+/blobs/[0-9a-f]{64}$ and nothing else. It does not check that the sqid is this document — fragment_html(doc) has no sqid — because the GET route does that by construction (D4).
3. The allowlist runs once, in the shared path, not per renderer. This is the correction that matters most. If the check lived only in node_html, then markdown.go would emit [text](javascript:…) and the PDF's /Link annotation would carry the same, unless each exporter re-implemented the check — a second copy of a security table, which is the exact coupling this plan claims to avoid. Instead a rejected href means the run is emitted with no link mark at all, in fragment_json/text_json as much as in fragment_html. No consumer can then see a rejected URL, because none is ever produced.
4. Accepted image types are sniffed, and SVG is not one of them. If /d/{sqid}/blobs/{hash} can serve image/svg+xml, navigating to it runs script on the app's own origin. JPEG, PNG, GIF and WebP only, sniffed from the bytes with http.DetectContentType and never taken from the client's header, served with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox. This is a safety rule, not an implementation detail of the upload route.
5. Adversarial tests in core/tests/fragment.rs, covering every item in 1 and 2 plus "><script> in title and alt.
D2 — tables are prosemirror-tables, not a hand-rolled node set
prosemirror-tables (1.8.5) is the same team's package, is what TipTap's table extension wraps, and brings the parts that are genuinely hard: cell selection, the column and row commands, and the fixups that keep a table rectangular after every edit. Hand-rolling those is how you get a model that can represent a broken grid.
It contributes table, table_row, table_cell, table_header, with colspan, rowspan and colwidth on cells. Its dependency ranges all dedupe onto the versions fixtures/package.json already pins — check with npm ls prosemirror-model after installing, because two copies of prosemirror-model break ProseMirror silently. It exports commands and the tableEditing() plugin but no keymap, so Tab and Shift-Tab are ours to bind, and it needs .selectedCell CSS for cell selection to be visible at all.
Column resizing is off in this slice. columnResizing is what populates colwidth, and colwidth is an array — a different shape from anything on the wire today. Off, it stays null, and y-prosemirror skips null attributes entirely. Google Docs' own cell widths arrive as style="width:…" rather than data-colwidth, so a paste leaves it null too. Resizing can come later with no migration.
colspan and rowspan are not null: they default to 1, so every cell carries both as numeric attributes. That is the one way the wire gets busier. Clamp both to 1..=1000 on the Rust side, the way heading.level is clamped. table_header renders <th scope="col">, a literal. No new closed set of values — these are node names, which every consumer already dispatches on by name.
Google Docs tables contain no <th>, so a paste produces zero table_header rows. <tbody> and <colgroup> are transparent to ProseMirror's DOMParser.
D3 — a link is a mark with an href, and the read view renders it
link is already in the browser schema and already round-trips. What is missing is the reader. Once D1 exists this is a row in MARKS — except MARKS is a table of literal open/close strings and link must compute its open tag, so the table grows a variant for a valued mark rather than gaining a second table beside it.
Rendered as <a href="…" rel="noreferrer">. Not noopener, which does nothing without target; noreferrer is the one that earns its place, because it stops the document's own URL — sqid and all — reaching the link target. No target: a document is a page, and opening a new tab is the reader's choice, not the author's.
internal/export/document.go has to grow with this: its Mark is {Type string} and its Attrs is closed to Level and Align, so both need widening and text_json needs to emit mark attributes.
D4 — the image is on screen at once; the node lands when the bytes do
Paul's brief is that an image renders immediately and uploads in the background. Revision 1 satisfied that by inserting the node with its final src up front — hashing the bytes in the browser so the URL was known before the upload. That was the wrong half of the trick, and this revision reverses it.
The reason is what the other windows see. A node inserted before its bytes exist reaches every other tab within ~250ms, where the GET 404s and the reader gets a broken image; a no-JavaScript reader gets the same; and a permanently failed upload leaves a dangling src in a CRDT every tab already holds, so "replace the node with its alt text" becomes cross-tab node surgery with no obvious owner. It did not even buy what it claimed: crypto.subtle.digest is async and transformPasted is synchronous, and the Google Docs path has no bytes at paste time at all, so a placeholder was always required.
So: render by decoration, insert on success. §6.1's own rule is that placeholders are decorations, never nodes. The local tab shows the blob: preview as a decoration — ProseMirror's own upload example — and the node is inserted into the CRDT only when the upload returns 2xx, at the decoration's mapped position. The decoration set maps through every intervening transaction; if an undo removed it, the result is discarded. Paul's brief is met exactly — instant on screen, upload async — and the entire dangling-src failure class disappears: no other tab and no reader ever sees a src that cannot resolve, and retry-then-drop becomes "remove the decoration and say so". Given the save queue's debounce, collaborators would not have seen the image sooner the other way round.
Once insertion follows the upload, the server computes the hash and returns the URL. The browser never hashes, which also disposes of crypto.subtle's secure-context requirement and of the client/server mismatch case.
The mapping table is still needed. blobs.Sealed.Put runs crypto.SealSym, which draws a random IV, so the stored address is the ciphertext's hash and is not derivable from the plaintext (§11.5 says as much). Store has no put-under-key. So:
documentblobs(documentid, plainhash, blobhash, contenttype, plainsize, createdat) unique (documentid, plain_hash)
plain_hash is what the URL carries and the CRDT holds; blob_hash is the blobs.Ref.Hash that Store.Get wants. content_type is a column because §11.5 requires the stored type to be application/octet-stream, so the true type cannot be recovered from the store at serve time. plain_size is the plaintext size, not Ref.Size, which is the sealed one. The unique constraint short-circuits a repeated paste of the same image before Put, so re-pasting does not mint a fresh ciphertext object every time.
Authorisation falls out of placement: GET /d/{sqid}/blobs/{hash} sits inside the existing owner-scoped group in app.go, and the handler resolves the document through Store.Get before looking up the row, so an image is exactly as reachable as the document referencing it — by construction, not by a second check somebody has to remember.
A sealing key does not exist yet, and that is a decision, not a line. Nothing in internal/ or cmd/ calls SealSym today; this is the first sealing the app does. It needs a key with the same fail-closed-outside- development shape as InstanceKey, and a stated relationship to the eventual per-document DEK. This needs Paul's call before Task 6.
Two things to add to the spec's §2.1 leak list while doing it: the plaintext SHA-256 sits in a plaintext column and in every access-log line for the URL (confirm-a-guess only, but new), and alt text rides in doc_updates.update_bin, which is itself still plaintext.
Copying an image between documents gives document B an <img src="/d/A/blobs/…"> that B's readers may not be able to fetch. The paste handler should recognise a same-origin /d/…/blobs/… src from another document, fetch it (same origin, no CORS) and re-upload under B.
D5 — what each renderer does with an image
image in prosemirror-schema-basic is inline, and Google Docs pastes images inside <p>, so it stays inline. That is the path of least resistance and it means the PDF breaks the line around an image rather than laying it out as a block.
- Read view (
fragment.rs):<img src="…" alt="…">, both escaped,srcmatched against D1's literal shape. No dimensions in this slice, so the page reflows as images load. - Markdown (
markdown.go):— and this is a dead link the moment the.mdleaves the app. Say so in the export rather than calling it lossless. - PDF (
pdf.goandinternal/pdf): JPEG embeds asDCTDecodeand PNG decodes withimage/pngand re-deflates — no rasterizer, so §11.4'sinternal/rasteris not pulled in. Three details revision 1 missed:internal/pdf'saddStreamFlate-compresses every stream unconditionally and needs an uncompressed variant for JPEG; the/Resourcesdict it builds carries only/Fontand needs/XObject; and PNG alpha needs a separate/SMaskXObject or the image renders on black, with 16-bit and palette PNGs decoded to 8-bit RGB first. CMYK JPEGs need/Decode [1 0 1 0 1 0 1 0]or should take the decode path. - A blob the store does not have renders as the alt text — in the Go exporters, which can ask the store.
fragment.rscannot: it is a pure function of the CRDT with no store access. The browser covers the residual race by retrying onimg.onerrorwith bounded backoff.
D6 — the Google Docs image, and what to test first
A Google Docs paste gives a googleusercontent.com URL, not bytes. Revision 1 assumed fetching it from the browser would be CORS-blocked and planned around that. The evidence points the other way: a googleusercontent.com fetch with a cross-origin Origin header returns access-control-allow-origin: * and cross-origin-resource-policy: cross-origin, from the same fife infrastructure that serves the lh7-rt.googleusercontent.com/docsz/… URLs in the clipboard HTML — which are signed-and-expiring rather than cookie-gated, so "credentialed" was probably wrong too.
That is one observation on one URL, not proof for docsz. So the test stands, but it moves: fetch a real Docs image URL cross-origin before building any of Task 7, because the expected answer is now "it works", and that changes the deliverable rather than confirming a fallback.
The real risk is expiry, not CORS: the URL dies after some window, so a paste sitting in a retry loop can go from fetchable to 403. Fetch early, once, and fail loudly.
Server-side fetching stays out. It is an SSRF surface with its own threat model and should not arrive as an implementation detail of a paste.
"Degrade loudly" needs something to degrade into, and it does not exist. There is no paste report anywhere in the codebase, and §11.2's import report is unbuilt. Both D4's failure path and D6's depend on it, so it is a task with UI, catalog keys and a copy review — not a sentence.
D7 — order, and why
Tables, then links, then images. Tables need no new safety property and no new storage. Links buy D1 and pay for it with adversarial tests. Images need D1, a new table, a new route, a sealing key, an upload path and PDF work — and carry D6.
D1 is not its own branch. Revision 1 proposed landing it with no caller "so it can be reviewed on its own"; AGENTS.md's own record is against that — devauth.go was deleted for having zero readers. It is the first commit of the links branch instead, reviewed as a commit.
The shape of the work
Task 1 — tables in the schema and on the wire
fixtures/editor-entry.js (the four node types, tableEditing(), Tab and Shift-Tab bound by hand), fixtures/package.json, fixtures/tables.test.mjs, the gate. Measure the bundle: roughly 40KB minified against the current 320KB, and visible in the diff because §6.5 forbids a build step.
Task 2 — tables in the three renderers
core/src/fragment.rs: the four arms, colspan/rowspan parsed and clamped, table_header → <th scope="col">. node_text needs a ruling: cells contain paragraphs, which already push their own entries into blocks, so either add a mode or accept one block per cell in the plain-text preview — the latter is fine for a list preview and should be stated rather than discovered. internal/export/markdown.go: GFM tables, whose delimiter row is mandatory and whose first row is the header — so a Google Docs table with no table_header needs its first row promoted. Pipes escape as \|. colspan, rowspan, multi-paragraph cells and lists in cells are outside GFM entirely and the export must say so rather than claiming losslessness. internal/export/pdf.go: bound this explicitly or it swallows the branch — equal column widths from the text column, cells wrap, a row that will not fit starts a new page, no cell merging across pages, colspan rendered, rowspan degraded to repeated cells.
Task 3 — tables on screen
views/show.html: table CSS in the [docs-text] vocabulary, borders that read as a document rather than a spreadsheet, overflow-x: auto on a wrapper so a wide table scrolls inside the page, and .selectedCell. fixtures/toolbar.mjs: insert table, add/remove row and column. WCAG 2.2 AA, every string a catalog key.
Task 4 — new wire fixtures
fixtures/generate-fragment.mjs hand-builds its vectors from y-prosemirror's described behaviour. That is fine for tables (element names and numeric attrs, the same shape as aligned.bin) and not fine for link, whose wire value is a mark.attrs object under the mark name — a shape no existing fixture has. Generate links.bin through y-prosemirror's own prosemirrorToYXmlFragment so Rust is tested against the real encoding rather than a transcription of it. tables.bin and images.bin alongside. Existing fixtures do not change; these are additions, and they are the gate for the new types.
Task 5 — the allowlist, then links
First commit: D1's grammar, the reject-to-text path in the shared fragment_json/text_json path, and the adversarial tests. Second: MARKS' valued variant, document.go's Mark and Attrs, markdown.go's [text](href), pdf.go's link styling and /Link annotation, toolbar.mjs's add/remove link.
Task 6 — the sealing key, the blob store and the route
Blocked on Paul's key decision. Then models.go plus a migration for document_blobs; blobs.Sealed over blobs.S3FromEnv() in cmd/docs/main.go, falling back to blobs.Dir for local runs; POST /d/{sqid}/blobs and GET /d/{sqid}/blobs/{hash} in the owner-scoped group, with the size cap, D1's sniffed type list and its response headers. §12's plaintext-grep gate already covers bucket objects.
Task 7 — the paste report
The thing every "degrade loudly" path needs and none of them has. UI, catalog keys, copy review.
Task 8 — images from a file
fixtures/image-upload.mjs (decoration, upload, retry-then-drop, reusing save-queue's shape) and its test; insertion on success at the mapped position; fragment.rs, markdown.go per D5.
Task 9 — images from Google Docs
Only after D6's fetch test. The cross-origin fetch, its expiry handling, and the report entry when it fails.
Task 10 — images in the PDF
internal/pdf's uncompressed stream variant, /XObject in /Resources, /SMask for alpha, the decode paths. Its own task because it is its own body of work.
Task 11 — copying an image between documents
Recognise a same-origin /d/…/blobs/… src from another document, fetch and re-upload under this one. Or an explicit "not in this slice" line — but not silence.
Risks
| Risk | Where | What to do | |---|---|---| | The Docs fetch behaves differently from the probe | D6 | Test a real Doc image first. It sets the shape of Tasks 9 and 7. | | A Docs URL expires mid-retry | D6 | Fetch early, once, fail loudly. | | No sealing key decision | D4, Task 6 | Paul's call. Blocks Task 6. | | PDF tables run away | Task 2 | The explicit bound above. | | PDF images touch internal/pdf's stream and resource assumptions | Task 10 | Its own task, not a corner of another. | | validate: "string" on image.src/link.href | All | A raw update with a non-string attr makes y-prosemirror delete the type. Pre-existing, but links and images widen it from "attrs the editor never writes" to "attrs every paste writes". Not this plan's to fix; recorded so it is not rediscovered as a mystery. | | Two prosemirror-model copies | Task 1 | npm ls prosemirror-model after install. Breaks silently. | | Two branches inventing the same vocabulary | All | Discussion #20 (worktree-import-documents) builds DOCX import, which produces these same nodes. Agree names and attrs there first. |
What this plan does not do
Headers and footers, page setup, comments, footnotes and tracked changes. Column resizing (D2). Image dimensions and lazy loading (D5). Server-side image fetching (D6). Underline, strikethrough, colour and highlight, which are a separate question about the attribute-free MARKS discipline rather than about these three nodes.