The core reports structure, and learns fonts and sizes
Two additions to core/src/fragment.rs, both in service of export.
fragment_json is a third renderer over the same walk: the document as
ProseMirror-shaped JSON. Markdown and PDF both need to know where a list
ends and which run is bold, and neither can get that from the HTML
renderer without a parser Go's standard library does not have. The ABI
gains docs_core_json_len/_read and goes to v3 — adding an export is
backwards compatible to the wasm, but internal/core's version check is
exact, so the bump turns "unknown export, deep inside a request" into a
refusal at load.
The MARKS table gains font_sans, font_mono, size_sm, size_lg and size_xl,
rendered as spans with fixed class names. Every one is attribute-free:
Store.HTML returns template.HTML because fragment.rs escapes every text
run AND puts no byte from a document into attribute position, and
POST /d/{sqid}/updates accepts arbitrary Yjs bytes that never went
through ProseMirror's schema. A mark carrying its own value would spend
that property. Serif and normal carry no mark — they are what a document
is when nobody has said otherwise.
mark_names sorts the marks it does not know. Attrs is a HashMap, so
without that the same document renders differently between two calls in
one process, which would make every golden file on the Go side flake
rather than fail.
internal/core's textOn/htmlOn became one readOn, and Text/HTML/JSON three
one-line wrappers over one read. It was two copies of the same forty
lines about to become three — three places for a fix like fix-round-1's
finding 3 (read the written count, not the buffer size) to be applied to
two of.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
9 files changed,
+716
−105
core/src/abi.rs+48 −1core/src/fragment.rs+204 −10core/src/lib.rs+16 −3core/tests/fragment.rs+148 −2docs/superpowers/plans/2026-08-31-formatting-and-export.md+208 −0fixtures/generate-fragment.mjs+22 −1fixtures/vectors/fragment/styled.binbinaryinternal/core/core.go+70 −88internal/core/testdata/docs_core.wasmbinary
diff --git a/core/src/abi.rs b/core/src/abi.rs| index 1b16cdd..9901174 100644 |
| --- a/core/src/abi.rs |
| +++ b/core/src/abi.rs |
| @@ -46,7 +46,7 @@ |
| //! held a stale handle is exactly the kind of silent corruption §5.2 |
| //! exists to avoid. |
| -use crate::{apply_update, fragment_html, fragment_text, CoreError}; |
| +use crate::{apply_update, fragment_html, fragment_json, fragment_text, CoreError}; |
| use std::cell::RefCell; |
| use std::collections::HashMap; |
| use yrs::{Doc, Options}; |
| @@ -148,6 +148,17 @@ pub fn html_string(handle: u32) -> Option<String> { |
| get_doc(handle).map(|doc| fragment_html(&doc)) |
| } |
| +/// `None` for an unknown handle — distinct from `Some(String::new())`, a |
| +/// real empty document (Finding 3), exactly as `text_string` and |
| +/// `html_string` above. Note that an empty document is never the empty |
| +/// *string* here: `fragment_json` always emits at least |
| +/// `{"type":"doc","content":[]}`, so a zero length from |
| +/// `docs_core_json_len` is unreachable for a live handle and the host |
| +/// need not treat it as a special case the way the other two do. |
| +pub fn json_string(handle: u32) -> Option<String> { |
| + get_doc(handle).map(|doc| fragment_json(&doc)) |
| +} |
| + |
| // --- wasm exports --- |
| // Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity |
| @@ -360,3 +371,39 @@ pub unsafe extern "C" fn docs_core_html_read(handle: u32, out_ptr: u32, out_len: |
| // larger, so clamp explicitly rather than rely on that coincidence. |
| s.len().try_into().unwrap_or(i32::MAX) |
| } |
| + |
| +/// Returns the byte length of `handle`'s JSON rendering, or `ERR_NO_DOC` |
| +/// if `handle` doesn't name a live document — distinct from 0, exactly |
| +/// as `docs_core_text_len` and `docs_core_html_len` above. (0 is not |
| +/// reachable here at all for a live handle; see `json_string`.) |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_json_len(handle: u32) -> i32 { |
| + match json_string(handle) { |
| + Some(s) => s.len().try_into().unwrap_or(i32::MAX), |
| + None => ERR_NO_DOC, |
| + } |
| +} |
| + |
| +/// # Safety |
| +/// |
| +/// `out_ptr` and `out_len` must describe a byte range that lies entirely |
| +/// within this module's linear memory and is valid for writes for the |
| +/// duration of this call. (Checked by `in_bounds` before any write — this |
| +/// safety requirement covers what that check cannot see, such as |
| +/// concurrent mutation.) |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_core_json_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { |
| + let s = match json_string(handle) { |
| + Some(s) => s, |
| + None => return ERR_NO_DOC, // stale/unknown handle, not an empty read |
| + }; |
| + if s.len() > out_len as usize { |
| + return ERR_BUFFER_TOO_SMALL; |
| + } |
| + if !in_bounds(out_ptr, out_len) { |
| + return ERR_INVALID_LENGTH; |
| + } |
| + std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len()); |
| + // Same clamp, same reason as the two read functions above. |
| + s.len().try_into().unwrap_or(i32::MAX) |
| +} |
diff --git a/core/src/fragment.rs b/core/src/fragment.rs| index fdf01f0..8c7145f 100644 |
| --- a/core/src/fragment.rs |
| +++ b/core/src/fragment.rs |
| @@ -35,30 +35,60 @@ fn escape(s: &str, out: &mut String) { |
| } |
| } |
| -/// Marks this build knows, in a fixed order so output is deterministic. |
| -/// A mark not listed here contributes no wrapper but never suppresses |
| -/// its text. |
| -const MARKS: &[(&str, &str)] = &[("strong", "strong"), ("em", "em"), ("code", "code")]; |
| +/// Marks this build knows, in a fixed order so output is deterministic: |
| +/// `(mark name, opening markup, closing markup)`. A mark not listed here |
| +/// contributes no wrapper but never suppresses its text. |
| +/// |
| +/// Every one of these is attribute-free on the CRDT side — the mark's |
| +/// presence is the whole of its meaning — and the markup each one emits |
| +/// is a compile-time constant chosen by lookup from this table. That is |
| +/// what keeps `Store.HTML`'s `template.HTML` return honest |
| +/// (`internal/docs/store.go`): `fragment_html` escapes every text run |
| +/// AND puts no byte from a document into attribute position, so the |
| +/// markup is safe to write out unescaped. A mark carrying a value — |
| +/// `link`'s `href`, or a `fontFamily` naming its own face — would break |
| +/// that, and `POST /d/{sqid}/updates` accepts arbitrary client-supplied |
| +/// Yjs bytes that never went through ProseMirror's schema, so the value |
| +/// would be genuinely arbitrary. Adding one is a real decision about |
| +/// attribute escaping and (for `href`) a scheme allowlist, not a row in |
| +/// this table. |
| +/// |
| +/// The font and size marks are ordered ahead of the semantic ones so a |
| +/// run carrying both nests as `<span class="…"><strong>…</strong></span>` |
| +/// rather than the other way round. Serif and "normal" carry no mark at |
| +/// all: they are what a document is when nobody has said otherwise, and |
| +/// spending a mark on the default would put a span around most of the |
| +/// text in most documents. |
| +const MARKS: &[(&str, &str, &str)] = &[ |
| + ("font_sans", "<span class=\"docs-font-sans\">", "</span>"), |
| + ("font_mono", "<span class=\"docs-font-mono\">", "</span>"), |
| + ("size_sm", "<span class=\"docs-size-sm\">", "</span>"), |
| + ("size_lg", "<span class=\"docs-size-lg\">", "</span>"), |
| + ("size_xl", "<span class=\"docs-size-xl\">", "</span>"), |
| + ("strong", "<strong>", "</strong>"), |
| + ("em", "<em>", "</em>"), |
| + ("code", "<code>", "</code>"), |
| +]; |
| fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) { |
| // Diff gives runs with their attributes, which is where marks live. |
| for chunk in t.diff(txn, yrs::types::text::YChange::identity) { |
| let mut open = Vec::new(); |
| if let Some(attrs) = &chunk.attributes { |
| - for (name, tag) in MARKS { |
| + for (name, o, c) in MARKS { |
| if attrs.contains_key(*name) { |
| - open.push(*tag); |
| + open.push((*o, *c)); |
| } |
| } |
| } |
| - for tag in &open { |
| - let _ = write!(out, "<{tag}>"); |
| + for (o, _) in &open { |
| + out.push_str(o); |
| } |
| if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert { |
| escape(s, out); |
| } |
| - for tag in open.iter().rev() { |
| - let _ = write!(out, "</{tag}>"); |
| + for (_, c) in open.iter().rev() { |
| + out.push_str(c); |
| } |
| } |
| } |
| @@ -193,3 +223,167 @@ pub fn fragment_text(doc: &Doc) -> String { |
| } |
| blocks.join("\n\n") |
| } |
| + |
| +// --- the structured renderer --- |
| +// |
| +// `fragment_html` above answers "what does this document look like"; |
| +// `fragment_json` below answers "what is this document made of". The |
| +// export paths (`internal/export`, `internal/pdf`) need the second |
| +// question answered: where a list ends, which run is bold, which |
| +// paragraph is a heading. Re-deriving that in Go by parsing the markup |
| +// this file emits would mean a parser to keep honest against an |
| +// emitter, and Go's standard library has no HTML parser to do it with. |
| +// |
| +// The shape is ProseMirror's own JSON — `{"type":..,"attrs":..,"content":[..]}`, |
| +// text as `{"type":"text","text":..,"marks":[..]}` — because that is the |
| +// vocabulary the editor on the other side of the wire already speaks, |
| +// so a Go consumer and a future JS one describe the same document the |
| +// same way. Written by hand rather than through serde: the crate has |
| +// no serialisation dependency outside dev-dependencies, and this is |
| +// one escape function and three walks. |
| + |
| +/// JSON string escaping, per RFC 8259: the two mandatory escapes, the |
| +/// three shorthands, and `\u00XX` for everything else below U+0020. |
| +/// Everything at or above U+0020 other than `"` and `\` goes through |
| +/// as-is — the output is UTF-8 and JSON says that is legal. |
| +fn escape_json(s: &str, out: &mut String) { |
| + for c in s.chars() { |
| + match c { |
| + '"' => out.push_str("\\\""), |
| + '\\' => out.push_str("\\\\"), |
| + '\n' => out.push_str("\\n"), |
| + '\r' => out.push_str("\\r"), |
| + '\t' => out.push_str("\\t"), |
| + c if (c as u32) < 0x20 => { |
| + let _ = write!(out, "\\u{:04x}", c as u32); |
| + } |
| + c => out.push(c), |
| + } |
| + } |
| +} |
| + |
| +/// The mark names on one text run: the ones this build knows in `MARKS` |
| +/// order, then any it does not, sorted. |
| +/// |
| +/// The sort is not cosmetic. `Attrs` is a `HashMap`, so iterating it |
| +/// yields an order that varies between runs of the same binary on the |
| +/// same document — an unsorted tail would make this renderer's output |
| +/// nondeterministic, which would make every golden-file test on the Go |
| +/// side flake rather than fail. Unknown marks are carried through |
| +/// rather than dropped for the same reason unknown *nodes* render their |
| +/// children (see the module comment): a consumer that ignores a mark it |
| +/// does not recognise still has the text, and one that grows to |
| +/// understand it needs it to have been there. |
| +fn mark_names(attrs: &yrs::types::Attrs) -> Vec<String> { |
| + let mut names: Vec<String> = MARKS |
| + .iter() |
| + .filter(|(name, _, _)| attrs.contains_key(*name)) |
| + .map(|(name, _, _)| (*name).to_string()) |
| + .collect(); |
| + let mut unknown: Vec<String> = attrs |
| + .keys() |
| + .map(|k| k.to_string()) |
| + .filter(|k| !MARKS.iter().any(|(name, _, _)| *name == k.as_str())) |
| + .collect(); |
| + unknown.sort(); |
| + names.append(&mut unknown); |
| + names |
| +} |
| + |
| +fn text_json<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String, first: &mut bool) { |
| + for chunk in t.diff(txn, yrs::types::text::YChange::identity) { |
| + let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert else { |
| + // A non-string insert is an embed. `fragment_html` renders |
| + // nothing for one today, and inventing a JSON shape for |
| + // something with no renderer would be guessing at a schema |
| + // rather than reporting one. |
| + continue; |
| + }; |
| + if s.is_empty() { |
| + continue; |
| + } |
| + if !*first { |
| + out.push(','); |
| + } |
| + *first = false; |
| + out.push_str("{\"type\":\"text\",\"text\":\""); |
| + escape_json(s, out); |
| + out.push('"'); |
| + if let Some(attrs) = &chunk.attributes { |
| + let names = mark_names(attrs); |
| + if !names.is_empty() { |
| + out.push_str(",\"marks\":["); |
| + for (i, name) in names.iter().enumerate() { |
| + if i > 0 { |
| + out.push(','); |
| + } |
| + out.push_str("{\"type\":\""); |
| + escape_json(name, out); |
| + out.push_str("\"}"); |
| + } |
| + out.push(']'); |
| + } |
| + } |
| + out.push('}'); |
| + } |
| +} |
| + |
| +fn node_json<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String, first: &mut bool) { |
| + match node { |
| + XmlOut::Text(t) => text_json(txn, t, out, first), |
| + XmlOut::Element(e) => { |
| + let name = e.tag().to_string(); |
| + if !*first { |
| + out.push(','); |
| + } |
| + *first = false; |
| + out.push_str("{\"type\":\""); |
| + escape_json(&name, out); |
| + out.push('"'); |
| + if name == "heading" { |
| + // Clamped exactly as `node_html` clamps it, and for the |
| + // same reason: a corrupt or hostile level must not reach |
| + // a consumer as `h0` or `h99`. Both renderers clamping |
| + // independently is deliberate — a document must not |
| + // paginate as one level on screen and export as another. |
| + let level = e |
| + .get_attribute(txn, "level") |
| + .and_then(|v| v.parse::<u8>().ok()) |
| + .unwrap_or(1) |
| + .clamp(1, 6); |
| + let _ = write!(out, ",\"attrs\":{{\"level\":{level}}}"); |
| + } |
| + out.push_str(",\"content\":["); |
| + let mut inner = true; |
| + for child in e.children(txn) { |
| + node_json(txn, &child, out, &mut inner); |
| + } |
| + out.push_str("]}"); |
| + } |
| + XmlOut::Fragment(f) => { |
| + // Flattened into the parent's content, exactly as |
| + // `node_html` renders a nested fragment's children with no |
| + // wrapper of its own. |
| + for child in f.children(txn) { |
| + node_json(txn, &child, out, first); |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// The document as ProseMirror-shaped JSON: `{"type":"doc","content":[..]}`. |
| +/// |
| +/// Deterministic for a given document state — see `mark_names` for the |
| +/// one place that took work — so a Go consumer's golden files are |
| +/// stable. |
| +pub fn fragment_json(doc: &Doc) -> String { |
| + let f = root(doc); |
| + let txn = doc.transact(); |
| + let mut out = String::from("{\"type\":\"doc\",\"content\":["); |
| + let mut first = true; |
| + for child in f.children(&txn) { |
| + node_json(&txn, &child, &mut out, &mut first); |
| + } |
| + out.push_str("]}"); |
| + out |
| +} |
diff --git a/core/src/lib.rs b/core/src/lib.rs| index a132909..31e847a 100644 |
| --- a/core/src/lib.rs |
| +++ b/core/src/lib.rs |
| @@ -55,7 +55,7 @@ |
| //! it won't. |
| pub mod abi; |
| mod fragment; |
| -pub use fragment::{fragment_html, fragment_text, ROOT as FRAGMENT_ROOT}; |
| +pub use fragment::{fragment_html, fragment_json, fragment_text, ROOT as FRAGMENT_ROOT}; |
| #[cfg(target_arch = "wasm32")] |
| #[no_mangle] |
| @@ -71,10 +71,23 @@ extern "Rust" fn __getrandom_v03_custom( |
| /// box fails loudly instead of behaving strangely. |
| /// |
| /// Bumped to 2 for the HTML read pair in `abi.rs` |
| -/// (`docs_core_html_len` / `docs_core_html_read`). |
| +/// (`docs_core_html_len` / `docs_core_html_read`), and to 3 for the JSON |
| +/// pair (`docs_core_json_len` / `docs_core_json_read`) the export paths |
| +/// read the document's structure through. |
| +/// |
| +/// Adding an export is, strictly, backwards compatible — a host that |
| +/// never calls the new function cannot tell 3 from 2 — so the bump is |
| +/// not forced by the wasm itself. It is forced by the host: the module |
| +/// and the Go binary ship embedded in each other with no version skew |
| +/// between separately deployed copies, and `internal/core`'s |
| +/// `abiVersion` is an exact-match check. A host that calls |
| +/// `docs_core_json_len` against a stale module gets "unknown export" at |
| +/// call time, deep inside a request; bumping means it gets a refusal at |
| +/// load time instead, which is the whole point of there being a version |
| +/// here. |
| #[no_mangle] |
| pub extern "C" fn docs_core_abi_version() -> u32 { |
| - 2 |
| + 3 |
| } |
| use yrs::updates::decoder::Decode; |
diff --git a/core/tests/fragment.rs b/core/tests/fragment.rs| index 44e67c4..53100ac 100644 |
| --- a/core/tests/fragment.rs |
| +++ b/core/tests/fragment.rs |
| @@ -1,6 +1,6 @@ |
| -use docs_core::{fragment_html, fragment_text}; |
| +use docs_core::{fragment_html, fragment_json, fragment_text}; |
| use yrs::updates::decoder::Decode; |
| -use yrs::{Doc, Options, Transact, Update, XmlFragment}; |
| +use yrs::{Doc, Options, Transact, Update, Xml, XmlFragment}; |
| fn doc_from(bytes: &[u8]) -> Doc { |
| let doc = Doc::with_options(Options::with_guid_and_client_id("fixture".into(), 1)); |
| @@ -144,3 +144,149 @@ fn nested_blocks_do_not_duplicate_their_text() { |
| } |
| assert_eq!(fragment_text(&doc), "x"); |
| } |
| + |
| +// --- the font and size marks, and the structured renderer --- |
| + |
| +// The class names below are the contract between this renderer and |
| +// views/show.html's stylesheet, and between it and internal/pdf's |
| +// mark handling. They are constants in this binary, never bytes from a |
| +// document — see the MARKS table's own comment. |
| +#[test] |
| +fn renders_font_and_size_marks_as_classed_spans() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin")); |
| + assert_eq!( |
| + fragment_html(&doc), |
| + concat!( |
| + "<p>", |
| + "<span class=\"docs-font-sans\">sans</span>", |
| + "<span class=\"docs-font-mono\"> mono</span>", |
| + "<span class=\"docs-size-lg\"> large</span>", |
| + "<span class=\"docs-font-sans\"><strong> both</strong></span>", |
| + "</p>", |
| + "<blockquote><p>Quoted.</p></blockquote>", |
| + "<ol><li><p>First</p></li><li><p>Second</p></li></ol>", |
| + ) |
| + ); |
| +} |
| + |
| +#[test] |
| +fn renders_the_document_as_prosemirror_json() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin")); |
| + assert_eq!( |
| + fragment_json(&doc), |
| + concat!( |
| + "{\"type\":\"doc\",\"content\":[", |
| + "{\"type\":\"heading\",\"attrs\":{\"level\":2},\"content\":[", |
| + "{\"type\":\"text\",\"text\":\"A heading\"}]},", |
| + "{\"type\":\"bullet_list\",\"content\":[", |
| + "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[", |
| + "{\"type\":\"text\",\"text\":\"One\"}]}]},", |
| + "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[", |
| + "{\"type\":\"text\",\"text\":\"Two\"}]}]}]}]}", |
| + ) |
| + ); |
| +} |
| + |
| +// Marks come out in MARKS order, not in whatever order the CRDT's own |
| +// attribute map happens to iterate — see mark_names. A run carrying two |
| +// of them is what actually exercises that. |
| +#[test] |
| +fn json_marks_are_in_a_fixed_order() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin")); |
| + let json = fragment_json(&doc); |
| + assert!( |
| + json.contains(concat!( |
| + "{\"type\":\"text\",\"text\":\" both\",\"marks\":[", |
| + "{\"type\":\"font_sans\"},{\"type\":\"strong\"}]}", |
| + )), |
| + "got {json}" |
| + ); |
| +} |
| + |
| +// Same document, rendered twice in one process: identical bytes. A |
| +// HashMap-ordered mark list would pass this only by luck, which is why |
| +// the run above carries two marks. |
| +#[test] |
| +fn json_is_deterministic() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin")); |
| + assert_eq!(fragment_json(&doc), fragment_json(&doc)); |
| +} |
| + |
| +// The JSON goes into an HTTP response body and, from there, into a Go |
| +// json.Unmarshal. A document whose text contains quotes, backslashes or |
| +// control characters must not be able to break out of its string. |
| +#[test] |
| +fn escapes_json_in_text() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("jesc".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p.push_back( |
| + &mut txn, |
| + yrs::XmlTextPrelim::new("a \"quote\" and a \\ and a \u{1}"), |
| + ); |
| + } |
| + assert_eq!( |
| + fragment_json(&doc), |
| + concat!( |
| + "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[", |
| + "{\"type\":\"text\",\"text\":\"a \\\"quote\\\" and a \\\\ and a \\u0001\"}]}]}", |
| + ) |
| + ); |
| +} |
| + |
| +// The module's rule — unknown nodes render their children rather than |
| +// disappearing — holds for the structured renderer too, with the node |
| +// itself reported under its own name so a consumer that grows to |
| +// understand it can. A newer editor build introducing a node this |
| +// binary has never heard of must not lose its text on export. |
| +#[test] |
| +fn json_keeps_unknown_nodes_and_their_children() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("junk".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let outer = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("table")); |
| + let p = outer.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p.push_back(&mut txn, yrs::XmlTextPrelim::new("kept")); |
| + } |
| + assert_eq!( |
| + fragment_json(&doc), |
| + concat!( |
| + "{\"type\":\"doc\",\"content\":[{\"type\":\"table\",\"content\":[", |
| + "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"kept\"}]}]}]}", |
| + ) |
| + ); |
| +} |
| + |
| +// A heading level is clamped identically by both renderers. A document |
| +// that reads as an h6 on screen must not export as an h99 — the two |
| +// clamps are written separately, so this is the test that keeps them |
| +// equal. |
| +#[test] |
| +fn json_clamps_heading_level_like_html() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("hclamp".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let h = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("heading")); |
| + h.insert_attribute(&mut txn, "level", "99"); |
| + h.push_back(&mut txn, yrs::XmlTextPrelim::new("Shouty")); |
| + } |
| + assert!(fragment_html(&doc).starts_with("<h6>"), "html clamp"); |
| + assert!( |
| + fragment_json(&doc).contains("\"attrs\":{\"level\":6}"), |
| + "json clamp: {}", |
| + fragment_json(&doc) |
| + ); |
| +} |
| + |
| +// An empty document is still a document: `{"type":"doc","content":[]}`, |
| +// never the empty string. internal/core's json read relies on that — |
| +// see abi.rs's json_string. |
| +#[test] |
| +fn json_of_an_empty_document_is_an_empty_doc_node() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1)); |
| + assert_eq!(fragment_json(&doc), "{\"type\":\"doc\",\"content\":[]}"); |
| +} |
diff --git a/docs/superpowers/plans/2026-08-31-formatting-and-export.md b/docs/superpowers/plans/2026-08-31-formatting-and-export.md| new file mode 100644 |
| index 0000000..4c07e76 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-31-formatting-and-export.md |
| @@ -0,0 +1,208 @@ |
| +# Plan 5 — the formatting bar, font options, and export |
| + |
| +Brief, from Paul, 2026-08-31: *"the maximally viable checklist — a |
| +formatting bar, some font options, at least PDF and Markdown export."* |
| + |
| +The spec already rules on all four (§11 Fonts/DOCX/PDF/Markdown, §13's |
| +sequencing item 7: "Markdown, then DOCX, then PDF"). This plan is that |
| +item, brought forward past DOCX — DOCX is import *and* export and needs |
| +the schema work §11.2 describes; Markdown and PDF need only what the |
| +CRDT already holds. Nothing here changes a spec ruling. Two places |
| +where this plan reads a ruling differently from a literal reading of |
| +§11 are recorded as D1 and D2 below, for the branch discussion to |
| +settle rather than for this plan to settle by itself. |
| + |
| +## What exists to build on |
| + |
| +- The document's truth is a `Y.XmlFragment` on root `prosemirror` |
| + (`core/src/fragment.rs`), replayed server-side through wazero. |
| + `fragment_html` and `fragment_text` are its two renderers today. |
| +- `internal/core` exposes that through a C ABI (`docs_core_html_len` / |
| + `_read`, and the text pair). `abiVersion` is 2. |
| +- The browser editor is ProseMirror + Yjs, one committed bundle |
| + (`fixtures/build-editor.mjs` → `internal/docs/static/editor.<hash>.js`). |
| + `prosemirror-schema-basic` + `addListNodes` is the schema; `Mod-b` and |
| + `Mod-i` are already bound to `toggleMark`, so bold and italic exist |
| + today with no way to *see* that they exist. |
| +- Every user-visible string is a catalog key (`internal/docs/render.go`). |
| + |
| +## The shape of the work |
| + |
| +Seven tasks. 1–3 are server-side and touch none of the files the |
| +concurrent caret-placement branch touches; 5–6 touch the editor bundle |
| +and are deliberately sequenced after that branch merges. |
| + |
| +### Task 1 — the core renders a structured document, not only markup |
| + |
| +`fragment.rs` gains `fragment_json`, a third renderer over the same |
| +walk: ProseMirror's own JSON shape (`{"type":…,"attrs":…,"content":[…]}`, |
| +text nodes as `{"type":"text","text":…,"marks":[…]}`), hand-written with |
| +no serde dependency (the crate has none outside dev-dependencies, and |
| +this is forty lines of escaping). |
| + |
| +The ABI gains `docs_core_json_len` / `docs_core_json_read`, the same |
| +len-then-read pair the other two use, and `abiVersion` goes to 3. |
| + |
| +**Why a third renderer rather than parsing the HTML in Go.** Markdown |
| +and PDF both need structure — where a list ends, which run is bold, |
| +which paragraph is a heading. Go's standard library has no HTML parser, |
| +and `golang.org/x/net/html` is a new module dependency this repo does |
| +not have and cannot fetch offline. Re-deriving structure from our own |
| +markup would also mean two parsers to keep honest against one emitter. |
| +One structured export, two consumers in Go, is fewer moving parts than |
| +either alternative. |
| + |
| +**Unknown nodes still render their children** — `fragment.rs`'s module |
| +rule, and it holds here for the same reason: a reader (or an exporter) |
| +silently dropping text is data loss, not a degradation. |
| + |
| +### Task 2 — font and size as marks the core knows |
| + |
| +Five new marks, all attribute-free: `font_sans`, `font_mono` (serif is |
| +the document default and carries no mark), and `size_sm`, `size_lg`, |
| +`size_xl` (normal carries no mark). |
| + |
| +`fragment.rs`'s `MARKS` table gains them, emitting a `<span>` with a |
| +fixed class — `<span class="docs-font-sans">` — chosen by lookup from a |
| +compile-time table. |
| + |
| +**Attribute-free marks, not a `fontFamily` mark with an attribute.** |
| +`Store.HTML` returns `template.HTML` — markup trusted to be written out |
| +unescaped — and its doc comment says exactly what earns that trust: |
| +`fragment.rs` escapes every text run *and emits no attributes at all*. |
| +A mark carrying a value from the CRDT into attribute position would put |
| +client-supplied bytes inside a quoted attribute, which is a different |
| +escaping problem with a different set of ways to get it wrong (and the |
| +CRDT accepts arbitrary client-posted bytes — `POST /d/{sqid}/updates` |
| +does not go through ProseMirror's schema). A fixed set of marks whose |
| +class names are constants in this binary keeps the property that made |
| +the `template.HTML` return safe in the first place: no byte from a |
| +document ever reaches attribute position. |
| + |
| +The cost is that the set is closed — a new size is a core change and a |
| +new binary, not a document that names its own point size. For "some |
| +font options" that is the right trade; §14 is where an open-ended font |
| +model goes if it is ever wanted. |
| + |
| +### Task 3 — `internal/fonts`: the core set, embedded |
| + |
| +Liberation Serif, Sans and Mono, four faces each (regular, bold, |
| +italic, bold-italic), embedded with `//go:embed` and served at |
| +`/static/fonts/…` behind the same immutable caching the editor bundle |
| +uses. |
| + |
| +**D1 — Liberation, where §11.1 names Carlito and Caladea.** §11.1 asks |
| +for two things that are separable, and this plan does the first |
| +without the second. The first is *a curated core set, self-hosted, the |
| +same font programs on the screen and in server-side layout, so both |
| +measure identically* — that is what makes a PDF match what the writer |
| +saw, and it is met here. The second is *metric-compatible substitution |
| +for the proprietary faces a DOCX import names*: Calibri → Carlito, |
| +Cambria → Caladea. That mapping only has a job once DOCX import exists |
| +to name Calibri, and there is no DOCX import. Liberation is the same |
| +idea one family over (it is the metric-compatible set for Times New |
| +Roman, Arial and Courier New), it is OFL-licensed, and it is what is |
| +actually available to this build. When DOCX import lands, Carlito and |
| +Caladea join the payload; nothing here forecloses that. |
| + |
| +**woff2 is not in this plan.** §11.1 says woff2 and means it — the TTFs |
| +are roughly twice the bytes. Converting requires `woff2_compress` or |
| +fontTools, neither of which is available here, and hand-writing a |
| +Brotli-based woff2 encoder to save download bytes is not a good use of |
| +this branch. The faces are declared with `@font-face` and a browser |
| +fetches only the faces a page actually uses, so an unused face costs |
| +nothing; the conversion is a follow-up with a real trigger (the first |
| +time page weight is measured and this shows up in it). |
| + |
| +### Task 4 — `internal/pdf`: a real PDF writer |
| + |
| +Pure Go, no new module dependencies, `CGO_ENABLED=0` like everything |
| +else: |
| + |
| +- **A TrueType reader** (`head`, `hhea`, `maxp`, `hmtx`, `cmap` format |
| + 4, `loca`, `glyf`) for advance widths and glyph outlines. |
| +- **A subsetter**: the glyphs a document actually uses, with `glyf`, |
| + `loca` and `hmtx` rebuilt and composite glyphs' components followed |
| + so an accented character does not lose its accent. |
| +- **The writer**: objects, an xref table, Flate-compressed content |
| + streams, and a Type0/CIDFontType2 font with Identity-H encoding plus |
| + a `ToUnicode` CMap, so text in the exported PDF is selectable and |
| + searchable rather than a picture of text. |
| +- **Layout** from the same page geometry the screen uses: US Letter, |
| + 1in margins, 12pt/1.6 body — the constants live in Go and |
| + `internal/docs` has a test that fails if `views/show.html` stops |
| + agreeing with them. §11.3's "the same box tree the screen renders" |
| + is a claim about the *engine* the layout plan will build; until that |
| + exists this is the honest version — one set of constants, asserted |
| + equal in both places, and a note that they are asserted rather than |
| + shared. |
| + |
| +**D2 — this is not the layout engine.** §13 says the layout engine |
| +cannot slip because pagination, PDF and thumbnails all fall out of it, |
| +and the engine is not built. What Task 4 builds is a line-breaker and a |
| +paginator for the document shapes that exist today (paragraphs, |
| +headings, lists, blockquotes, inline marks) — real text measurement |
| +from real font metrics, not an approximation, but it knows nothing |
| +about tables, images, floats or widow/orphan control. When the layout |
| +engine lands, this becomes its back end (the writer, the subsetter and |
| +the font work all survive) and the line-breaking half of it is |
| +replaced. Recording it here so a later reader does not mistake this for |
| +the engine and build the engine's other consumers on top of it. |
| + |
| +### Task 5 — the formatting bar |
| + |
| +`fixtures/toolbar.mjs`, a new module under the 300-line cap, wired into |
| +`editor-entry.js` in a few lines. Bold, italic, code, link; paragraph |
| +and headings 1–3; bullet and ordered lists; blockquote; undo and redo; |
| +a font-family control and a size control driving Task 2's marks. |
| + |
| +Chrome, so it lives outside `[docs-editor]` — ProseMirror's |
| +`{mount: el}` takes over everything inside that element. Buttons |
| +reflect the selection's active marks, targets are 24×24 (§10.3, WCAG |
| +2.2 AA, no exemptions), every label is a catalog key rendered |
| +server-side into the markup the way the save-status copy already is — |
| +the bundle carries no copy of its own. |
| + |
| +Each command dispatches one transaction, so one toolbar click costs one |
| +retained update, not several: `internal/core`'s `maxRetainedUpdates` is |
| +4096 with no compaction yet, and toolbar-driven editing spends it |
| +faster than typing does. |
| + |
| +### Task 6 — the export routes and where a person clicks them |
| + |
| +`GET /d/{sqid}/export.md` and `GET /d/{sqid}/export.pdf`, both inside |
| +the `RequireSession` group and both owner-scoped through the same |
| +`Store.Get` every other document route uses — a document someone else |
| +owns 404s identically to one that does not exist (§4.2), with no |
| +separate check that could 403 by mistake. |
| + |
| +`Content-Disposition: attachment` with a filename derived from the |
| +document title, sanitised to a conservative set and falling back to the |
| +sqid — a title is arbitrary user text and a header is the wrong place |
| +to find that out. |
| + |
| +The controls go in `show.html`'s page header next to sign-out. |
| + |
| +### Task 7 — the gate |
| + |
| +`.amadan/ci.d/10-gate` already covers everything this adds (Rust tests, |
| +Go tests, the bundle-freshness check, gofmt). What this task adds is |
| +the tests themselves: fragment JSON fixtures on the Rust side, Markdown |
| +golden files, a PDF that is parsed back and asserted structurally |
| +(pages, fonts embedded, text extractable) rather than byte-compared, |
| +the geometry agreement test, and a toolbar test under `node --test` for |
| +whatever logic can be tested without a DOM. |
| + |
| +## Risks |
| + |
| +- **The bundle is a single committed artifact and only one may exist** |
| + (`assets.go` panics at init otherwise). This branch rebuilds it once, |
| + at the end, after the concurrent caret-placement branch has merged — |
| + a rebuild is not hand-mergeable. |
| +- **`maxRetainedUpdates` = 4096, no compaction.** Not this branch's to |
| + fix, made worse by this branch. Task 5's one-transaction-per-command |
| + rule is the mitigation available without opening compaction. |
| +- **PDF is the long pole.** If it does not finish, Markdown, the |
| + toolbar and the fonts still ship on their own and the branch is |
| + still worth merging; the reverse is not true, so PDF goes last of |
| + the server-side tasks and first of the ones that could be cut. |
diff --git a/fixtures/generate-fragment.mjs b/fixtures/generate-fragment.mjs| index 1f1b332..1a213e8 100644 |
| --- a/fixtures/generate-fragment.mjs |
| +++ b/fixtures/generate-fragment.mjs |
| @@ -54,6 +54,27 @@ writeFileSync('vectors/fragment/nested.bin', docWith(f => { |
| ]) |
| })) |
| +// styled.bin covers what the formatting bar can produce that the three |
| +// fixtures above cannot: the font and size marks (core/src/fragment.rs's |
| +// MARKS table), a run carrying two marks at once, and the two block |
| +// shapes — blockquote and ordered_list — that had no fixture before the |
| +// export paths needed to render them. The marks are attribute-free on |
| +// purpose; see that table's own comment for why a mark never carries a |
| +// value. |
| +writeFileSync('vectors/fragment/styled.bin', docWith(f => { |
| + f.insert(0, [ |
| + el('paragraph', null, |
| + text('sans', { font_sans: {} }), |
| + text(' mono', { font_mono: {} }), |
| + text(' large', { size_lg: {} }), |
| + text(' both', { font_sans: {}, strong: {} })), |
| + el('blockquote', null, el('paragraph', null, text('Quoted.'))), |
| + el('ordered_list', null, |
| + el('list_item', null, el('paragraph', null, text('First'))), |
| + el('list_item', null, el('paragraph', null, text('Second')))), |
| + ]) |
| +})) |
| + |
| // --- trap-containment vectors --- |
| // |
| // core/tests/wasm_trap_containment.rs mutation-fuzzes real fixtures and |
| @@ -168,4 +189,4 @@ emitTrap('concurrent', (doc) => { |
| Y.applyUpdate(doc, Y.encodeStateAsUpdate(a)) |
| }) |
| -console.log('wrote 3 fragment vectors + 4 trap-containment vectors') |
| +console.log('wrote 4 fragment vectors + 4 trap-containment vectors') |
diff --git a/fixtures/vectors/fragment/styled.bin b/fixtures/vectors/fragment/styled.bin| new file mode 100644 |
| index 0000000..df87016 |
| Binary files /dev/null and b/fixtures/vectors/fragment/styled.bin differ |
diff --git a/internal/core/core.go b/internal/core/core.go| index da6b0ac..188fc04 100644 |
| --- a/internal/core/core.go |
| +++ b/internal/core/core.go |
| @@ -62,7 +62,7 @@ var moduleWASM []byte |
| // anything else is refused at load, so a stale .wasm (see |
| // scripts/build-wasm.sh and the copy step in Task 6's brief) fails loudly |
| // rather than behaving strangely. |
| -const abiVersion = 2 |
| +const abiVersion = 3 |
| // defaultGuestCallTimeout bounds every single call into the wasm module, |
| // unless overridden with WithGuestCallTimeout. |
| @@ -529,78 +529,45 @@ func (h *Handle) Apply(ctx context.Context, update []byte) error { |
| // empty string plus an error instead of it would tell the caller the data |
| // is gone when it is not. |
| func (h *Handle) Text(ctx context.Context) (string, error) { |
| - text, err := textOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| - if err == nil { |
| - return text, nil |
| - } |
| - if !isFatal(err) { |
| - return "", err |
| - } |
| - if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| - return "", errors.Join(err, rebuildErr) |
| - } |
| - text, err = textOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| - if err != nil { |
| - return "", fmt.Errorf("reading text from the rebuilt instance: %w", err) |
| - } |
| - return text, nil |
| + return h.read(ctx, "text") |
| } |
| -func textOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64) (string, error) { |
| - lenRes, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_text_len"), docHandle) |
| - if err != nil { |
| - return "", fmt.Errorf("text_len trapped or timed out: %w", err) |
| - } |
| - // text_len returns i32: negative is an error code, not a length. Reading |
| - // it as unsigned would turn ERR_NO_DOC into 0xFFFFFFFF and then try to |
| - // allocate four gigabytes. |
| - if code := int32(lenRes[0]); code < 0 { |
| - return "", fmt.Errorf("text_len failed: code %d", code) |
| - } |
| - n := lenRes[0] |
| - if n == 0 { |
| - return "", nil |
| - } |
| - |
| - ptr, err := allocIn(ctx, timeout, mod, n) |
| - if err != nil { |
| - return "", fmt.Errorf("alloc: %w", err) |
| - } |
| - defer freeIn(ctx, timeout, mod, ptr, n) |
| - |
| - rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_text_read"), docHandle, ptr, n) |
| - if err != nil { |
| - return "", fmt.Errorf("text_read trapped or timed out: %w", err) |
| - } |
| - written := int32(rc[0]) |
| - if written < 0 { |
| - return "", fmt.Errorf("text_read failed: code %d", written) |
| - } |
| +// HTML returns the document's current content rendered as HTML (spec |
| +// §6.2: the read view is server-rendered, no script required). |
| +func (h *Handle) HTML(ctx context.Context) (string, error) { |
| + return h.read(ctx, "html") |
| +} |
| - // docs_core_text_read returns s.len(), guaranteed only <= out_len |
| - // (core/src/abi.rs:287 — the buffer sized by text_len is an upper |
| - // bound, not a promise the two calls agree exactly). Reading n bytes |
| - // instead of the written count would, if they ever diverged, append |
| - // uninitialised allocator bytes past the real content to the returned |
| - // string (fix-round-1 finding 3) — read exactly what text_read says it |
| - // wrote. |
| - buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) |
| - if !ok { |
| - return "", fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) |
| - } |
| - return string(buf), nil |
| +// JSON returns the document's structure as ProseMirror-shaped JSON — |
| +// what the export paths (internal/export, internal/pdf) read, since |
| +// Markdown and PDF both need to know where a list ends and which run is |
| +// bold, and neither can get that from HTML without a parser. See |
| +// core/src/fragment.rs's fragment_json. |
| +// |
| +// The bytes are always a complete JSON document for a live handle, |
| +// never "" — an empty document is {"type":"doc","content":[]} — so a |
| +// caller may unmarshal the result without a special case for empty. |
| +func (h *Handle) JSON(ctx context.Context) (string, error) { |
| + return h.read(ctx, "json") |
| } |
| -// HTML returns the document's current content rendered as HTML (spec |
| -// §6.2: the read view is server-rendered, no script required). Mirrors |
| -// Text exactly, including the trap/timeout-then-rebuild-then-retry-once |
| -// behaviour: if the read itself traps or times out, the instance is |
| -// discarded and rebuilt, and the read is retried once against the freshly |
| -// rebuilt instance rather than reporting the content as gone. |
| -func (h *Handle) HTML(ctx context.Context) (string, error) { |
| - html, err := htmlOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| +// read is the shared body of Text, HTML and JSON: call the named |
| +// renderer's len/read pair, and if the call itself traps or times out, |
| +// discard the instance, rebuild it exactly as Apply does, and retry the |
| +// read once against the fresh one. Retrying rather than reporting an |
| +// error is deliberate — the content is available at that point, and |
| +// returning an empty string plus an error would tell the caller the |
| +// data is gone when it is not. |
| +// |
| +// One function rather than three near-identical ones. It was three |
| +// (textOn/htmlOn, and jsonOn was about to be the third): the same forty |
| +// lines with two identifiers substituted, which is three places for a |
| +// fix like fix-round-1's finding 3 (read the written count, not the |
| +// buffer size) to be applied to two of. |
| +func (h *Handle) read(ctx context.Context, renderer string) (string, error) { |
| + s, err := readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| if err == nil { |
| - return html, nil |
| + return s, nil |
| } |
| if !isFatal(err) { |
| return "", err |
| @@ -608,26 +575,40 @@ func (h *Handle) HTML(ctx context.Context) (string, error) { |
| if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| return "", errors.Join(err, rebuildErr) |
| } |
| - html, err = htmlOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| + s, err = readOn(ctx, h.rt.callTimeout, h.mod, h.handle, renderer) |
| if err != nil { |
| - return "", fmt.Errorf("reading html from the rebuilt instance: %w", err) |
| + return "", fmt.Errorf("reading %s from the rebuilt instance: %w", renderer, err) |
| } |
| - return html, nil |
| + return s, nil |
| } |
| -// htmlOn is textOn's HTML counterpart — the same two-call |
| -// len-then-read dance against docs_core_html_len/docs_core_html_read |
| -// instead of the text pair. |
| -func htmlOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64) (string, error) { |
| - lenRes, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_html_len"), docHandle) |
| +// readOn performs one renderer's len-then-read dance against |
| +// docs_core_<renderer>_len and docs_core_<renderer>_read. renderer is |
| +// always one of this package's own three literals ("text", "html", |
| +// "json"), never anything derived from a request — the export names it |
| +// builds are a closed set, and a name that does not resolve to an |
| +// exported function surfaces below as a nil api.Function. |
| +func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, renderer string) (string, error) { |
| + lenFn := mod.ExportedFunction("docs_core_" + renderer + "_len") |
| + readFn := mod.ExportedFunction("docs_core_" + renderer + "_read") |
| + if lenFn == nil || readFn == nil { |
| + // Unreachable while abiVersion matches — the version check at |
| + // load is what proves these exports exist — but a nil |
| + // api.Function panics inside callGuest rather than erroring, so |
| + // this turns a future ABI mistake into a returned error instead |
| + // of a panic in a request handler. |
| + return "", fmt.Errorf("module exports no %q renderer", renderer) |
| + } |
| + |
| + lenRes, err := callGuest(ctx, timeout, lenFn, docHandle) |
| if err != nil { |
| - return "", fmt.Errorf("html_len trapped or timed out: %w", err) |
| + return "", fmt.Errorf("%s_len trapped or timed out: %w", renderer, err) |
| } |
| - // html_len returns i32: negative is an error code, not a length. Reading |
| - // it as unsigned would turn ERR_NO_DOC into 0xFFFFFFFF and then try to |
| - // allocate four gigabytes. |
| + // The len functions return i32: negative is an error code, not a |
| + // length. Reading it as unsigned would turn ERR_NO_DOC into |
| + // 0xFFFFFFFF and then try to allocate four gigabytes. |
| if code := int32(lenRes[0]); code < 0 { |
| - return "", fmt.Errorf("html_len failed: code %d", code) |
| + return "", fmt.Errorf("%s_len failed: code %d", renderer, code) |
| } |
| n := lenRes[0] |
| if n == 0 { |
| @@ -640,21 +621,22 @@ func htmlOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandl |
| } |
| defer freeIn(ctx, timeout, mod, ptr, n) |
| - rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_html_read"), docHandle, ptr, n) |
| + rc, err := callGuest(ctx, timeout, readFn, docHandle, ptr, n) |
| if err != nil { |
| - return "", fmt.Errorf("html_read trapped or timed out: %w", err) |
| + return "", fmt.Errorf("%s_read trapped or timed out: %w", renderer, err) |
| } |
| written := int32(rc[0]) |
| if written < 0 { |
| - return "", fmt.Errorf("html_read failed: code %d", written) |
| + return "", fmt.Errorf("%s_read failed: code %d", renderer, written) |
| } |
| - // docs_core_html_read returns s.len(), guaranteed only <= out_len — the |
| - // buffer sized by html_len is an upper bound, not a promise the two |
| - // calls agree exactly, exactly as docs_core_text_read. Reading n bytes |
| + // The read functions return s.len(), guaranteed only <= out_len |
| + // (core/src/abi.rs — the buffer sized by the len call is an upper |
| + // bound, not a promise the two calls agree exactly). Reading n bytes |
| // instead of the written count would, if they ever diverged, append |
| - // uninitialised allocator bytes past the real content to the returned |
| - // string — read exactly what html_read says it wrote. |
| + // uninitialised allocator bytes past the real content to the |
| + // returned string (fix-round-1 finding 3) — read exactly what the |
| + // read call says it wrote. |
| buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) |
| if !ok { |
| return "", fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| index 6a93072..45e7c36 100755 |
| Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ |