Print Markup: comments in PDF, print, images and text copies (#589) (#667)

Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-11 23:38:29 -04:00
committed by GitHub
co-authored by Dmitrii Khlapov Claude Opus 5.5 Brandon Thomas
parent 381c475edc
commit f7a84fbfb8
34 changed files with 852 additions and 53 deletions
+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Print Markup, #589; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **481 / 496 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **482 / 497 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -146,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
| 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments |
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
@@ -198,6 +199,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
| 2026-10-11 | trivial | Progress log: Print Markup (#589) |
| 2026-10-11 | trivial | Progress log: custom colours via More Colors… (#606) |
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
+9 -4
View File
@@ -12,7 +12,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png)
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
@@ -74,6 +77,7 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@@ -147,13 +151,14 @@ fn run(args: &[String]) -> Result<(), Failure> {
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
validate_options("convert", &rest, NO_OPTIONS)?;
validate_options("convert", &rest, CONVERT_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1}))
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
s.run("file.save", &json!({"path": o}))
s.run("file.save", &json!({"path": o, "markup": markup}))
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
+1
View File
@@ -13,6 +13,7 @@ File|Backstage|Open|file.open
File|Backstage|Save|file.save
File|Backstage|Save As|file.saveAs
File|Backstage|Print|file.print
File|Backstage|Print Markup|file.printMarkup
File|Backstage|Close|file.close
File|Backstage|Info|file.info
File|Backstage|Properties|file.properties
+23 -8
View File
@@ -35,9 +35,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -46,14 +46,22 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
let opts = export_options(s, v);
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
s.prefs.print_markup = p::bool(v, "value").unwrap_or(!s.prefs.print_markup);
Ok(json!({"value": s.prefs.print_markup}))
})
.params(r#"{"value"?: bool (omit to toggle)} — PDFs, prints and copies show comments (balloons beside each page; a list at the end of HTML, Markdown and text) and tracked changes"#)
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true)}"#,
@@ -178,7 +186,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
@@ -196,11 +204,18 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout();
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
let png = img.to_png();
+27 -8
View File
@@ -39,6 +39,17 @@ pub fn is_word_package(ext: &str) -> bool {
wordcraft_docx::Flavor::from_ext(ext).is_some()
}
/// How a copy in another format is written.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExportOptions {
/// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled
/// down beside a markup area with the comments in balloons (page images get the area too);
/// HTML, Markdown and text copies end with the comments.
pub markup: bool,
/// With `markup`, show the comments (Track Changes Options › Comments).
pub comments: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Password(zeroize::Zeroizing<String>);
@@ -140,12 +151,20 @@ fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>)
/// Serialise a document; `name` gives the format by extension.
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
save_bytes_opts(name, doc, &ExportOptions::default())
}
/// [`save_bytes`] with export options (Print Markup).
pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let ext = ext_of(name);
match ext.as_str() {
// List paragraphs get their label and a tab, as in Word's plain-text save.
"txt" | "text" => Ok(wordcraft_formats::txt::export(doc)),
"txt" | "text" => {
let bytes = wordcraft_formats::txt::export(doc);
Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes })
}
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
other => match crate::io_ext::save(other, doc) {
other => match crate::io_ext::save(other, doc, opts) {
Some(r) => r,
None => Err(format!("{name}: can't save as `.{other}`")),
},
@@ -154,8 +173,8 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package
/// (other formats are copies and are written as they are).
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>) -> Result<Vec<u8>, String> {
let bytes = save_bytes(name, doc)?;
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let bytes = save_bytes_opts(name, doc, opts)?;
match password {
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
@@ -294,13 +313,13 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
}
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
save_path_with(path, doc, None)
save_path_with(path, doc, None, &ExportOptions::default())
}
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password)?;
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
// Write atomically: temp file next to the target, then rename.
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
@@ -308,7 +327,7 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
}
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
+12 -7
View File
@@ -14,22 +14,27 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
}
/// Save to a format other than plain text / JSON.
pub fn save(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option<Result<Vec<u8>, String>> {
// .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects.
if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) {
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
}
match ext {
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0)),
"pdf" => Some(wordcraft_pdf::export(doc, &Default::default()).map_err(|e| e.to_string())),
other => wordcraft_formats::export(other, doc),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
let pdf = wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, ..Default::default() };
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
}
}
/// Render page `page` as PNG at `scale` px/pt.
pub fn render_png(doc: &Document, page: usize, scale: f32) -> Result<Vec<u8>, String> {
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
/// markup area with the comments in balloons (page images always show tracked changes).
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())
+5 -1
View File
@@ -139,12 +139,16 @@ impl Session {
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
/// distinct pages chosen and copies. Does not touch the document.
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
let lay = self.export_layout();
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
let opts = wordcraft_pdf::PdfOptions {
pages: Some(plan.pages.clone()),
// Repeated pages would repeat the structure tree's entries too.
tagged: plan.copies <= 1,
include_markup: markup.markup,
comments: markup.comments,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
+13 -1
View File
@@ -335,6 +335,10 @@ pub struct Prefs {
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
/// File › Print / Export: Print Markup. PDFs, prints and page images show comments in
/// balloons beside each page and tracked changes as marked up; HTML, Markdown and text
/// copies end with the comments (`file.printMarkup`).
pub print_markup: bool,
/// Language › Detect language automatically (per user, as in Word).
pub detect_language: bool,
/// Typing over a selection replaces it; off, the typed text goes in front of it.
@@ -363,6 +367,7 @@ impl Default for Prefs {
Prefs {
count_notes: true,
markup: Default::default(),
print_markup: false,
typing_replaces_selection: true,
insert_key_overtype: false,
overtype: false,
@@ -537,6 +542,7 @@ impl Session {
show_hidden: self.view.marks || self.prefs.marks.hidden,
hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(),
proofing: self.view.proofing,
comment_balloons: false,
field_codes: self.view.field_codes,
outline: outline.clone(),
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
@@ -547,7 +553,13 @@ impl Session {
}
/// A layout for output (PDF, images, print): no proofing marks, print view.
pub fn export_layout(&self) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, ..Default::default() };
self.export_layout_with(false)
}
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
/// area with the comments in balloons.
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, comment_balloons: markup && self.prefs.markup.comments, ..Default::default() };
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
}
+51
View File
@@ -3218,6 +3218,57 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
/// Print Markup (#589): `file.printMarkup` or a command's `markup` puts the comments into PDFs
/// (balloons beside the scaled page) and at the end of HTML, Markdown and text copies.
#[test]
fn print_markup_exports_comments() {
let mut s = Session::new(wordcraft_doc::Document::from_text("The quarterly figures look strong."));
s.author = "Ada Reviewer".into();
s.run("select.text", &json!({"text": "figures"})).unwrap();
let id = s.run("review.newComment", &json!({"text": "Check the totals"})).unwrap()["id"].as_u64().unwrap();
s.run("review.reply", &json!({"id": id, "text": "Totals checked"})).unwrap();
let opts = |s: &Session, v| cmd::file::export_options(s, &v);
assert!(!opts(&s, json!({})).markup, "off by default");
let pdf = |s: &Session, v| crate::io::save_bytes_opts("x.pdf", &s.doc, &opts(s, v)).unwrap();
let html = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.html", &s.doc, &opts(s, v)).unwrap()).unwrap();
let txt = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.txt", &s.doc, &opts(s, v)).unwrap()).unwrap();
assert!(!html(&s, json!({})).contains("Check the totals"));
assert!(!txt(&s, json!({})).contains("Check the totals"));
let plain = pdf(&s, json!({}));
// The setting turns it on for every copy; a command's `markup` overrides it.
assert_eq!(s.run("file.printMarkup", &json!({})).unwrap()["value"], true);
let h = html(&s, json!({}));
assert!(
h.contains("<section class=\"comments\">") && h.contains("Ada Reviewer") && h.contains("<q>figures</q>") && h.contains("Totals checked"),
"{h}"
);
assert!(h.find("Check the totals") < h.find("</body>"));
let t = txt(&s, json!({}));
assert!(t.contains("[1] Ada Reviewer on “figures”: Check the totals") && t.contains("Totals checked"), "{t}");
let md = String::from_utf8(crate::io::save_bytes_opts("x.md", &s.doc, &opts(&s, json!({}))).unwrap()).unwrap();
assert!(md.contains("## Comments") && md.contains("1. **Ada Reviewer**"), "{md}");
assert_ne!(pdf(&s, json!({})), plain);
assert!(!html(&s, json!({"markup": false})).contains("Check the totals"));
// Comments hidden in Track Changes Options stay out.
s.prefs.markup.comments = false;
assert!(!html(&s, json!({})).contains("Check the totals"));
s.prefs.markup.comments = true;
assert_eq!(s.run("file.printMarkup", &json!({"value": false})).unwrap()["value"], false);
assert!(html(&s, json!({"markup": true})).contains("Check the totals"));
// file.exportPdf and file.exportPng take it too.
let dir = std::env::temp_dir().join(format!("wordcraft-print-markup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("m.pdf");
let r = s.run("file.exportPdf", &json!({"path": path.to_string_lossy(), "markup": true})).unwrap();
assert_eq!(r["markup"], true);
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
let png = dir.join("m.png");
let wide = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "markup": true, "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
let narrow = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
assert!(wide > narrow + 200, "{wide} vs {narrow}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #606: the colour commands behind More Colors… take any `RRGGBB` (with or without `#`, any
/// case), not only the palette's colours; junk is a params error and changes nothing.
#[test]
+200
View File
@@ -0,0 +1,200 @@
//! Print Markup for the text formats: HTML, Markdown and plain text copies end with the
//! document's comments, numbered in document order, each with its author, the text it comments
//! on, what it says and its replies (like the notes at the end of a printed document).
use std::collections::{BTreeMap, HashMap, HashSet};
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::{Document, StoryRef};
/// Longest quoted anchor text (characters).
const MAX_QUOTE: usize = 200;
const MAX_COMMENTS: usize = 5_000;
const MAX_REPLIES: usize = 200;
const MAX_PARENT_CHAIN: usize = 64;
/// One comment as written out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
pub author: String,
/// The text it comments on (shortened), or empty.
pub quote: String,
pub text: String,
/// (author, text) of each reply, oldest first.
pub replies: Vec<(String, String)>,
}
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
}
}
cur
}
fn author(a: &str) -> String {
if a.trim().is_empty() { "Author".to_string() } else { a.trim().to_string() }
}
fn text_of(doc: &Document, part: u32) -> String {
doc.plain_text(StoryRef::Part(part)).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The document's top-level comments: those anchored in the body in document order, then the
/// rest; replies go under the comment they answer.
pub fn notes(doc: &Document) -> Vec<Note> {
if doc.comments.is_empty() {
return Vec::new();
}
// Anchor order and the text between each comment's start and end.
let mut order: Vec<u32> = Vec::new();
let mut seen = HashSet::new();
let mut quotes: HashMap<u32, String> = HashMap::new();
let mut open: Vec<u32> = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (off, c) in p.text.char_indices() {
if c == OBJ {
match p.object_at(off) {
Some(InlineObject::CommentStart { id }) => {
if seen.insert(*id) {
order.push(*id);
}
if !open.contains(id) && open.len() < 64 {
open.push(*id);
}
}
Some(InlineObject::CommentEnd { id }) => open.retain(|x| x != id),
_ => {}
}
continue;
}
for id in &open {
let q = quotes.entry(*id).or_default();
if q.chars().count() <= MAX_QUOTE {
q.push(c);
}
}
}
for id in &open {
quotes.entry(*id).or_default().push(' ');
}
}
for id in doc.comments.keys() {
if seen.insert(*id) {
order.push(*id);
}
}
let mut replies: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for id in doc.comments.keys() {
let r = root(doc, *id);
if r != *id {
let v = replies.entry(r).or_default();
if v.len() < MAX_REPLIES {
v.push(*id);
}
}
}
let mut out = Vec::new();
for id in order {
let Some(c) = doc.comments.get(&id) else { continue };
if root(doc, id) != id {
continue;
}
let quote = quotes.get(&id).map(|q| q.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default();
let quote = if quote.chars().count() > MAX_QUOTE { format!("{}…", quote.chars().take(MAX_QUOTE).collect::<String>()) } else { quote };
let replies =
replies.get(&id).into_iter().flatten().filter_map(|r| doc.comments.get(r)).map(|r| (author(&r.author), text_of(doc, r.part))).collect();
out.push(Note { author: author(&c.author), quote, text: text_of(doc, c.part), replies });
if out.len() >= MAX_COMMENTS {
break;
}
}
out
}
/// `bytes` (a copy in format `ext`) with the comments written at its end.
pub fn append(ext: &str, doc: &Document, bytes: Vec<u8>) -> Vec<u8> {
let notes = notes(doc);
if notes.is_empty() {
return bytes;
}
match ext {
"html" | "htm" | "xhtml" => {
let mut s = match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => return e.into_bytes(),
};
let at = s.rfind("</body>").unwrap_or(s.len());
s.insert_str(at, &html(&notes));
s.into_bytes()
}
"md" | "markdown" => {
let mut s = bytes;
s.extend_from_slice(markdown(&notes).as_bytes());
s
}
"txt" | "text" => {
let mut s = bytes;
s.extend_from_slice(text(&notes).as_bytes());
s
}
_ => bytes,
}
}
fn html(notes: &[Note]) -> String {
use crate::html::esc;
let mut s = String::from("<section class=\"comments\">\n<h2>Comments</h2>\n<ol>\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("<li id=\"comment-{}\"><p><strong>{}</strong>", i + 1, esc(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on <q>{}</q>", esc(&n.quote)));
}
s.push_str(&format!(": {}</p>", esc(&n.text)));
if !n.replies.is_empty() {
s.push_str("<ul>");
for (a, t) in &n.replies {
s.push_str(&format!("<li><strong>{}</strong>: {}</li>", esc(a), esc(t)));
}
s.push_str("</ul>");
}
s.push_str("</li>\n");
}
s.push_str("</ol>\n</section>\n");
s
}
fn markdown(notes: &[Note]) -> String {
use crate::markdown::escape;
let mut s = String::from("\n\n## Comments\n\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("{}. **{}**", i + 1, escape(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", escape(&n.quote)));
}
s.push_str(&format!(": {}\n", escape(&n.text)));
for (a, t) in &n.replies {
s.push_str(&format!(" - **{}**: {}\n", escape(a), escape(t)));
}
}
s
}
fn text(notes: &[Note]) -> String {
let mut s = String::from("\r\n\r\nComments\r\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("\r\n[{}] {}", i + 1, n.author));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", n.quote));
}
s.push_str(&format!(": {}\r\n", n.text));
for (a, t) in &n.replies {
s.push_str(&format!(" {a}: {t}\r\n"));
}
}
s
}
+20
View File
@@ -9,6 +9,7 @@
//! depth-limited and sizes are capped.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod comments;
pub mod html;
pub mod latex;
pub mod markdown;
@@ -55,5 +56,24 @@ pub fn export(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
})
}
/// How [`export_with`] writes a copy.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExportOptions {
/// Print Markup: HTML, Markdown and plain text end with the document's comments (who wrote
/// each, the text it comments on, what it says, its replies). Other formats keep comments
/// their own way or not at all.
pub comments: bool,
}
/// [`export`] with options.
pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option<Result<Vec<u8>, String>> {
let out = export(ext, doc)?;
if !opts.comments {
return Some(out);
}
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
Some(out.map(|bytes| comments::append(&ext, doc, bytes)))
}
#[cfg(test)]
mod tests;
+1 -1
View File
@@ -1013,7 +1013,7 @@ fn emit(mut nodes: Vec<Node>) -> Vec<Inline> {
// Export
/// Escape text for Markdown (inline context).
fn escape(s: &str) -> String {
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
+235
View File
@@ -0,0 +1,235 @@
//! Print Markup: the comments in balloons in a markup area on the right of each page, joined to
//! the text they comment on, the way Word prints and exports a document "showing markup".
//!
//! [`crate::layout`] calls [`add`] when [`crate::LayoutOptions::comment_balloons`] is on: each
//! page grows by [`MARKUP_AREA`] ([`crate::Page::markup`]) and gets, for each comment anchored on
//! it, a card (author colour stripe and border) holding the author's name and the comment's own
//! laid-out text, with its replies under it, and a leader from the anchor to the card. The cards
//! are ordinary placed items, so the PDF writer, the renderer and print draw them unchanged
//! (the PDF writer scales each page down to its paper size). The editor draws its own interactive
//! balloons instead.
//!
//! Hostile documents: comment, reply and parent-chain counts are capped; cards that don't fit
//! are clipped to the page, and when a page has more comments than its markup area can hold the
//! ones that find no room are left out.
use std::collections::{BTreeMap, HashSet};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{Border, BorderStyle, CharProps, Rgb, TextColor};
use wordcraft_doc::{Document, Paragraph, Pos, StoryRef};
use wordcraft_geom::Rect;
use crate::display::revision_color;
use crate::{BoxLayout, Ctx, DocLayout, Placed, fit_box, layout_box, push_para};
/// Width of the markup area beside each page (points): the editor's.
pub const MARKUP_AREA: f32 = 216.0;
/// Space between the page and the cards, and on their right.
const SIDE: f32 = 9.0;
/// Space between cards.
const GAP: f32 = 6.0;
/// Padding inside a card.
const PAD: f32 = 5.0;
/// The author-colour stripe on a card's left.
const STRIPE: f32 = 3.0;
/// Space kept above the first and below the last card on a page.
const EDGE: f32 = 12.0;
/// Smallest card height when cards have to be clipped to fit a page.
const MIN_CARD: f32 = 18.0;
const MAX_BALLOONS: usize = 5_000;
const MAX_REPLIES: usize = 200;
const MAX_PARENT_CHAIN: usize = 64;
/// Path given to the synthetic author-name paragraphs: no paragraph of the comment's story has it.
const HEADER_PATH: u32 = u32::MAX;
/// One comment's balloon before it is positioned: items relative to the card's top-left.
struct Card {
page: usize,
/// The anchor: the comment's start, at the foot of its line.
ax: f32,
ay: f32,
items: Vec<Placed>,
h: f32,
color: Rgb,
}
/// The thread a comment belongs to: the comment at the top of its parent chain.
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
}
}
cur
}
/// Each top-level comment anchored in the body, with where it starts, in document order. Replies
/// have no balloon of their own: they go under the comment they answer.
pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
let Some(InlineObject::CommentStart { id }) = p.object_at(off) else { continue };
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
continue;
}
out.push((*id, Pos { story: StoryRef::Body, path: path.clone(), off }));
if out.len() >= MAX_BALLOONS {
return out;
}
}
}
out
}
/// Replies by the top-level comment they belong to, oldest (lowest id) first.
pub fn replies(doc: &Document) -> BTreeMap<u32, Vec<u32>> {
let mut m: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for id in doc.comments.keys() {
let r = root(doc, *id);
if r != *id {
let v = m.entry(r).or_default();
if v.len() < MAX_REPLIES {
v.push(*id);
}
}
}
m
}
/// The comment authors in order of first appearance (for their colours).
fn author_index(doc: &Document, author: &str) -> u32 {
let mut seen: Vec<&str> = Vec::new();
for c in doc.comments.values() {
if !seen.contains(&c.author.as_str()) {
seen.push(&c.author);
}
}
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
}
fn author_name(a: &str) -> &str {
if a.trim().is_empty() { "Author" } else { a }
}
/// Widen every page by the markup area and place the comment balloons in it.
pub(crate) fn add(ctx: &mut Ctx, lay: &mut DocLayout) {
let doc = ctx.doc;
if doc.comments.is_empty() || lay.pages.is_empty() {
return;
}
let anchors = anchors(doc);
if anchors.is_empty() {
return;
}
let threads = replies(doc);
let card_w = MARKUP_AREA - 2.0 * SIDE;
let inner = card_w - STRIPE - 2.0 * PAD;
let mut cards: Vec<Card> = Vec::new();
for (id, pos) in anchors {
let Some(caret) = lay.caret_on(&pos, 0) else { continue };
let Some(c) = doc.comments.get(&id) else { continue };
let color = revision_color(author_index(doc, &c.author));
let mut thread = vec![id];
thread.extend(threads.get(&id).into_iter().flatten().copied());
let x = STRIPE + PAD;
let mut items = Vec::new();
let mut y = PAD;
for (k, cid) in thread.iter().enumerate() {
let Some(cm) = doc.comments.get(cid) else { continue };
let story = StoryRef::Part(cm.part);
if k > 0 {
// A reply starts: a hairline between it and what it answers.
y += 3.0;
let border = Border { style: BorderStyle::Single, width: 0.25, color: Some(Rgb(0xC8, 0xC8, 0xC8)), space: 0.0 };
items.push(Placed::Rule { x0: x, y0: y, x1: x + inner, y1: y, border });
y += 3.0;
}
// Who wrote it, in their colour.
let head = Paragraph::with_text(
author_name(&cm.author),
CharProps { bold: Some(true), size: Some(9.0), color: Some(TextColor::Rgb(color)), ..Default::default() },
);
let pl = ctx.para_labelled(&head, inner, None, &[], None);
let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0));
push_para(&mut items, story, &[HEADER_PATH], &pl, 0, pl.lines.len(), x, y + lead, inner);
y += pl.height + 2.0;
// The comment itself, as written (its own formatting).
if let Some(part) = doc.parts.get(&cm.part) {
let BoxLayout { items: body, height, .. } = layout_box(ctx, story, &part.blocks, &[], inner, None, 1, None);
for mut it in body {
it.translate(x, y);
items.push(it);
}
y += height;
}
}
cards.push(Card { page: caret.page, ax: caret.x, ay: caret.top + caret.height, items, h: y + PAD, color });
}
// Every page gets the markup area, so the pages keep one size.
let widths: Vec<f32> = lay.pages.iter().map(|p| p.w).collect();
for p in &mut lay.pages {
p.w += MARKUP_AREA;
p.markup = MARKUP_AREA;
}
let mut by_page: BTreeMap<usize, Vec<Card>> = BTreeMap::new();
for c in cards {
by_page.entry(c.page).or_default().push(c);
}
for (pi, mut list) in by_page {
let (Some(page), Some(&pw)) = (lay.pages.get_mut(pi), widths.get(pi)) else { continue };
list.sort_by(|a, b| a.ay.total_cmp(&b.ay).then(a.ax.total_cmp(&b.ax)));
let (top_min, bottom) = (EDGE, (page.h - EDGE).max(EDGE + MIN_CARD));
// Each card as near its anchor as the ones above allow; then pushed up from the page
// foot; then down again below the top, clipping what still doesn't fit.
let mut tops: Vec<f32> = Vec::with_capacity(list.len());
let mut next = top_min;
for c in &list {
let t = (c.ay - 10.0).max(next);
tops.push(t);
next = t + c.h + GAP;
}
let mut limit = bottom;
for (t, c) in tops.iter_mut().zip(&list).rev() {
if *t + c.h > limit {
*t = limit - c.h;
}
limit = *t - GAP;
}
let mut next = top_min;
let x0 = pw + SIDE;
for (c, t) in list.into_iter().zip(tops) {
let top = t.max(next);
// No room left beside this page (dozens of comments on it): the rest are left out
// rather than drawn off the paper.
if top + MIN_CARD > bottom + 0.5 {
break;
}
let avail = bottom - top;
let (items, h) = if c.h > avail { (fit_box(c.items, avail - PAD), avail) } else { (c.items, c.h) };
next = top + h + GAP;
let line = |style, width: f32| Border { style, width, color: Some(c.color), space: 0.0 };
// Leader: along the text from the anchor to the page edge, then to the card.
page.items.push(Placed::Rule { x0: c.ax, y0: c.ay, x1: pw, y1: c.ay, border: line(BorderStyle::Dashed, 0.5) });
page.items.push(Placed::Rule { x0: pw, y0: c.ay, x1: x0, y1: top + 8.0, border: line(BorderStyle::Single, 0.5) });
// The card: white, outlined and striped in the author's colour.
let card = Rect::new(x0, top, card_w, h);
page.items.push(Placed::Fill { rect: card, color: Rgb(0xFF, 0xFF, 0xFF) });
page.items.push(Placed::Fill { rect: Rect::new(x0, top, STRIPE, h), color: c.color });
let (l, r, b) = (card.x, card.right(), card.bottom());
for (ax, ay, bx, by) in [(l, top, r, top), (r, top, r, b), (l, b, r, b), (l, top, l, b)] {
page.items.push(Placed::Rule { x0: ax, y0: ay, x1: bx, y1: by, border: line(BorderStyle::Single, 0.5) });
}
for mut it in items {
it.translate(x0, top);
page.items.push(it);
}
}
}
}
+14 -1
View File
@@ -8,6 +8,7 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod balloons;
mod contour;
pub mod display;
pub mod fields;
@@ -61,6 +62,10 @@ pub struct LayoutOptions {
pub hide_deleted: bool,
/// Check spelling as you type (squiggles).
pub proofing: bool,
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
/// and show the comments there in balloons joined to their anchors, as Word prints them.
/// The editor draws its own interactive balloons and leaves this off.
pub comment_balloons: bool,
/// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9).
/// A field whose own `code` flag is set shows the other one.
pub field_codes: bool,
@@ -317,6 +322,9 @@ pub struct Page {
pub footer_story: Option<u32>,
/// Top-level body block index at the start of the page.
pub first_block: usize,
/// Width of the markup area on the right of `w` that holds comment balloons (Print Markup,
/// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide.
pub markup: f32,
}
/// The whole layout.
@@ -1786,7 +1794,12 @@ fn layout_pass(
}
}
}
Pass::Done(DocLayout { pages, index, ms: 0.0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) })
let mut lay = DocLayout { pages, index, ms: 0.0, text_boxes: 0 };
if opts.comment_balloons && !web {
balloons::add(&mut ctx, &mut lay);
}
lay.text_boxes = ctx.boxes.total().saturating_sub(boxes_discarded);
Pass::Done(lay)
}
/// Pagination state saved before a block, to lay the blocks from there out again (balancing
+98
View File
@@ -3172,6 +3172,104 @@ fn footnote_longer_than_pages_ends() {
assert!(pieces.windows(2).all(|w| w[0].2 == w[1].1 && w[1].0 == w[0].0 + 1), "{pieces:?}");
}
/// A comment by `author` saying `text` on body paragraph `para`, bytes `a..b`; its id.
fn comment_on(d: &mut Document, para: usize, a: usize, b: usize, author: &str, text: &str, parent: Option<u32>) -> u32 {
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text(text, CharProps::default()))]);
let id = d.comments.keys().next_back().map_or(0, |k| k + 1);
d.comments.insert(id, wordcraft_doc::Comment { author: author.into(), part, parent, ..Default::default() });
if parent.is_none() {
let p = CharProps::default();
d.insert_object(&Pos::body(para, b), InlineObject::CommentEnd { id }, &p).unwrap();
d.insert_object(&Pos::body(para, a), InlineObject::CommentStart { id }, &p).unwrap();
}
id
}
fn balloon_layout(d: &Document) -> DocLayout {
layout(d, &mut LayoutCache::new(), &LayoutOptions { comment_balloons: true, ..Default::default() })
}
/// The comment text lines placed on page `pi` (story, top-left).
fn comment_lines(l: &DocLayout, pi: usize) -> Vec<(StoryRef, f32, f32)> {
l.pages[pi]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: s @ StoryRef::Part(_), x, y, .. } => Some((*s, *x, *y)),
_ => None,
})
.collect()
}
/// Print Markup (#589): each page grows by the markup area, and the comment, its author and its
/// reply are laid out there beside the line they comment on.
#[test]
fn comment_balloons_widen_pages_beside_their_anchors() {
let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line.");
let id = comment_on(&mut d, 1, 4, 13, "Ada Reviewer", "Check the totals", None);
comment_on(&mut d, 0, 0, 0, "Ben Editor", "Totals checked", Some(id));
let plain = lay(&d);
let l = balloon_layout(&d);
assert_eq!(l.pages.len(), plain.pages.len());
let (w, pw) = (l.pages[0].w, plain.pages[0].w);
assert_eq!(l.pages[0].markup, balloons::MARKUP_AREA);
assert!((w - pw - balloons::MARKUP_AREA).abs() < 0.01, "{w} vs {pw}");
assert_eq!(plain.pages[0].markup, 0.0);
// Author + text for the comment, then for the reply: four lines, all in the markup area.
let lines = comment_lines(&l, 0);
assert_eq!(lines.len(), 4, "{lines:?}");
assert!(lines.iter().all(|(_, x, _)| *x > pw && *x < w), "{lines:?}");
assert!(lines.windows(2).all(|p| p[0].2 < p[1].2), "stacked top to bottom: {lines:?}");
// The card sits by the commented line.
let anchor = l.caret(&Pos::body(1, 5)).unwrap();
assert!((lines[0].2 - anchor.top).abs() < 30.0, "{lines:?} vs {anchor:?}");
// The body is laid out exactly as without balloons.
assert_eq!(lines_of(&plain), lines_of(&l) - 4);
// The leader starts at the anchor.
assert!(l.pages[0].items.iter().any(|it| matches!(it, Placed::Rule { x0, x1, .. } if (*x0 - anchor.x).abs() < 0.5 && (*x1 - pw).abs() < 0.5)));
}
#[test]
fn no_balloons_without_anchored_comments() {
let d = Document::from_text("No comments here.");
let l = balloon_layout(&d);
assert_eq!(l.pages[0].markup, 0.0);
assert_eq!(l.pages[0].w, lay(&d).pages[0].w);
// A web view never gets a markup area.
let mut d = Document::from_text("Hello there.");
comment_on(&mut d, 0, 0, 5, "A", "x", None);
let opts = LayoutOptions { view: ViewMode::Web, web_width: 500.0, comment_balloons: true, ..Default::default() };
let l = layout(&d, &mut LayoutCache::new(), &opts);
assert_eq!(l.pages[0].markup, 0.0);
}
/// Hostile comments: parent cycles, a missing text part, and far more comments than fit beside
/// one page. Nothing panics or hangs, and the cards stay on the page.
#[test]
fn comment_balloons_survive_hostile_comments() {
let mut d = Document::from_text(&"Commented words here. ".repeat(20));
let mut ids = Vec::new();
for i in 0..120 {
ids.push(comment_on(&mut d, 0, 0, 9, &format!("Author {i}"), &"A long remark that wraps over lines. ".repeat(4), None));
}
// A reply cycle and a comment whose text part is gone.
let a = comment_on(&mut d, 0, 0, 0, "R", "one", Some(ids[0]));
let b = comment_on(&mut d, 0, 0, 0, "R", "two", Some(a));
d.comments.get_mut(&a).unwrap().parent = Some(b);
d.comments.get_mut(&ids[1]).unwrap().parent = Some(ids[1]);
d.comments.get_mut(&ids[2]).unwrap().part = 9_999_999;
let l = balloon_layout(&d);
let page = &l.pages[0];
for (_, _, y) in comment_lines(&l, 0) {
assert!(y.is_finite() && y >= 0.0 && y <= page.h, "{y}");
}
for it in &page.items {
if let Placed::Fill { rect, .. } = it {
assert!(rect.y >= 0.0 && rect.bottom() <= page.h + 0.5, "{rect:?}");
}
}
}
/// The first paragraph's lines on page one: (top, bottom, left, right), relative to its column.
fn line_spans(l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { return Vec::new() };
+55 -11
View File
@@ -78,20 +78,25 @@ pub struct PdfOptions {
pub tagged: bool,
/// Show tracked changes as markup (coloured, underlined / struck through).
pub include_markup: bool,
/// Print Markup's comments: each page is scaled down to make room on its right for the
/// comments in balloons joined to their anchors (Word's "Document showing markup").
pub comments: bool,
/// Compress content streams.
pub compress: bool,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, compress: true }
PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, comments: false, compress: true }
}
}
/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked
/// deletions are left out, as in Word's "No Markup" view.
/// deletions are left out, as in Word's "No Markup" view. With `comments` each page carries the
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let lay = layout(doc, &mut LayoutCache::new(), &LayoutOptions { hide_deleted: !opts.include_markup, ..Default::default() });
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout(doc, &lay, opts)
}
@@ -155,16 +160,26 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
}
for (out, &pi) in pages.iter().enumerate() {
for &pi in pages {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi + 1)) };
let (w, h) = (clamp_side(page.w), clamp_side(page.h));
let fit = Fit::of(page);
let (w, h) = (clamp_side(fit.paper_w), clamp_side(page.h));
let size = Size::from_wh(w, h).ok_or(PdfError::NoPages)?;
let mut kp = pdf.start_page_with(PageSettings::new(size));
let mut s = kp.surface();
ex.links.clear();
// A page with a markup area is scaled down to fit its paper with the area beside it.
let scaled = fit.k < 1.0;
if scaled {
s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, 0.0, fit.dy));
}
// `w` is the document page's width: the page colour and watermark stay off the markup area.
ex.page(&mut s, page, w, h);
if scaled {
s.pop();
}
s.finish();
for a in ex.annotations(out) {
for a in ex.annotations(fit) {
kp.add_annotation(a);
}
kp.finish();
@@ -185,6 +200,32 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
}
}
/// How a layout page maps onto its PDF page: a page with a markup area (Print Markup) is scaled
/// by `k` to its paper width and centred vertically (`dy`); any other page is drawn as it is.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fit {
paper_w: f32,
k: f32,
dy: f32,
}
impl Fit {
fn of(page: &Page) -> Fit {
let paper_w = page.w - page.markup;
if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 {
let k = paper_w / page.w;
Fit { paper_w, k, dy: page.h * (1.0 - k) / 2.0 }
} else {
Fit { paper_w: page.w, k: 1.0, dy: 0.0 }
}
}
/// A layout point on the PDF page.
fn point(self, x: f32, y: f32) -> (f32, f32) {
(x * self.k, y * self.k + self.dy)
}
}
/// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates.
fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option<Path> {
let db = wordcraft_fonts::FontDb::global();
@@ -1196,8 +1237,9 @@ impl Exporter<'_> {
let places = self.lay.index.get(&(story, path.clone()))?;
for (pi, ii) in places {
let Some(out) = self.out_index.get(pi) else { continue };
let (x, y) = match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) {
Some(Placed::Lines { x, y, .. }) => (*x, *y),
let Some(page) = self.lay.pages.get(*pi) else { continue };
let (x, y) = match page.items.get(*ii) {
Some(Placed::Lines { x, y, .. }) => Fit::of(page).point(*x, *y),
_ => (0.0, 0.0),
};
return Some((*out, x, y));
@@ -1205,10 +1247,11 @@ impl Exporter<'_> {
None
}
fn annotations(&self, _out: usize) -> Vec<Annotation> {
fn annotations(&self, fit: Fit) -> Vec<Annotation> {
let mut v = Vec::new();
for (x0, y0, x1, y1, target) in &self.links {
let Some(rect) = krilla::geom::Rect::from_ltrb(*x0, *y0, *x1, *y1) else { continue };
let ((x0, y0), (x1, y1)) = (fit.point(*x0, *y0), fit.point(*x1, *y1));
let Some(rect) = krilla::geom::Rect::from_ltrb(x0, y0, x1, y1) else { continue };
let t = if let Some(name) = target.strip_prefix('#') {
match self.bookmark(name) {
Some((page, x, y)) => Target::Destination(XyzDestination::new(page, Point::from_xy(x, y)).into()),
@@ -1239,7 +1282,8 @@ impl Exporter<'_> {
if title.is_empty() {
continue;
}
flat.push((level.min(8), title, out, *x, *y));
let (x, y) = Fit::of(page).point(*x, *y);
flat.push((level.min(8), title, out, x, y));
}
}
if flat.is_empty() {
+37
View File
@@ -798,3 +798,40 @@ fn bookmark_destination_is_the_page_of_its_start() {
assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}");
assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0));
}
/// Print Markup (#589): the comments and their replies print beside each page, which is scaled
/// down to keep its paper size; without it they stay out of the PDF.
#[test]
fn comments_print_in_balloons_beside_scaled_pages() {
let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line.");
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("Check the totals", CharProps::default()))]);
d.comments.insert(0, wordcraft_doc::Comment { author: "Ada Reviewer".into(), part, ..Default::default() });
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("Totals checked", CharProps::default()))]);
d.comments.insert(1, wordcraft_doc::Comment { author: "Ben Editor".into(), part, parent: Some(0), ..Default::default() });
d.insert_object(&wordcraft_doc::Pos::body(1, 13), InlineObject::CommentEnd { id: 0 }, &CharProps::default()).unwrap();
d.insert_object(&wordcraft_doc::Pos::body(1, 4), InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
let size = |bytes: &[u8]| {
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
pdf.pages().iter().map(|p| p.media_box()).map(|r| (r.width().round(), r.height().round())).collect::<Vec<_>>()
};
let plain = export(&d, &PdfOptions::default()).unwrap();
let text = squash(&extract_text(&plain).concat());
assert!(text.contains("quarterly figures") && !text.contains("Check the totals") && !text.contains("Ada Reviewer"), "{text}");
let marked = export(&d, &PdfOptions { include_markup: true, comments: true, ..Default::default() }).unwrap();
let text = squash(&extract_text(&marked).concat());
for want in ["quarterly figures", "Ada Reviewer", "Check the totals", "Ben Editor", "Totals checked"] {
assert!(text.contains(want), "{want} missing: {text}");
}
// Same paper: the page and its markup area are scaled to fit it.
assert_eq!(size(&plain), size(&marked));
assert_eq!(size(&marked), vec![(612.0, 792.0)]);
// Tagged or not, with links and an outline, it still writes.
let mut d2 = sample();
let part = d2.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("On the heading", CharProps::default()))]);
d2.comments.insert(0, wordcraft_doc::Comment { author: "Ada".into(), part, ..Default::default() });
d2.insert_object(&wordcraft_doc::Pos::body(1, 3), InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
for tagged in [false, true] {
let bytes = export(&d2, &PdfOptions { tagged, comments: true, ..Default::default() }).unwrap();
assert!(squash(&extract_text(&bytes).concat()).contains("On the heading"));
}
}
+5 -3
View File
@@ -421,11 +421,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
if raw_w <= 0.0 {
return;
}
let diag = if wm.diagonal { ((page.w as f64).powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { page.w as f64 };
// The document page, not a Print Markup area beside it.
let pw = (page.w - page.markup.max(0.0)).max(1.0) as f64;
let diag = if wm.diagonal { (pw.powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { pw };
let size = (diag * 0.5 / raw_w).min(200.0);
let k = size / upem;
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / page.w.max(1.0) as f64).atan() } else { 0.0 };
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0))
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / pw).atan() } else { 0.0 };
let base = Affine::translate((pw / 2.0, page.h.min(2000.0) as f64 / 2.0))
* Affine::rotate(angle)
* Affine::translate((-raw_w * size / 2.0, size * 0.35));
ctx.set_paint(color(opts.ink(wm.color), if wm.semitransparent { 0.35 } else { 0.8 }));
+12
View File
@@ -333,6 +333,18 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
fn export_page(app: &mut WordApp, ui: &mut Ui) {
if app.ui.backstage_page == "print" {
heading(ui, "Print");
// Print Markup (a print setting, as in Word): the comments beside each page.
let mut markup = app.session.prefs.print_markup;
if ui
.checkbox(&mut markup, tl!("Print Markup"))
.on_hover_text(tl!(
"Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text."
))
.changed()
{
let _ = app.run("file.printMarkup", json!({"value": markup}));
}
ui.add_space(12.0);
crate::print_page::show(app, ui);
return;
}
+2
View File
@@ -1415,6 +1415,8 @@
Formatted Con formato
Interface size: Tamaño de la interfaz:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte.
Print Markup Imprimir marcas
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentarios junto a cada página y cambios marcados en PDF, impresiones e imágenes; una lista de comentarios al final de páginas web, Markdown y texto.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Restricciones de formato
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1401,6 +1401,8 @@
Formatted Vormindatud
Interface size: Kasutajaliidese suurus:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
Print Markup Prindi märgistus
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentaarid iga lehe kõrval ja jälitatud muudatused PDF-ides, väljatrükkides ja piltides; kommentaaride loend veebilehtede, Markdowni ja teksti lõpus.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Vormingupiirangud
Can't render this file because it contains an unexpected character in line 445 and column 13.
+2
View File
@@ -1399,6 +1399,8 @@
Formatted 書式変更
Interface size: 表示サイズ:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。
Print Markup 変更履歴とコメントを印刷
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF、印刷、画像では各ページの横にコメントと変更履歴を表示し、Web ページ、Markdown、テキストでは末尾にコメント一覧を付けます。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 書式の制限
Can't render this file because it contains an unexpected character in line 553 and column 13.
+2
View File
@@ -1411,6 +1411,8 @@
Formatted Formatado
Interface size: Tamanho da interface:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado.
Print Markup Imprimir marcação
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentários ao lado de cada página e alterações controladas em PDFs, impressões e imagens; uma lista de comentários no final de páginas da web, Markdown e texto.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Restrições de formatação
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1790,6 +1790,8 @@ preview Title Пример
Left Only Только слева
Right Only Только справа
Largest Only Только по большей стороне
Print Markup Печатать исправления и примечания
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста.
Recent Colors Последние цвета
More Colors… Другие цвета…
Standard Обычные
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1409,6 +1409,8 @@
Formatted Formatirano
Interface size: Veličina interfejsa:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno.
Print Markup Štampaj oznake
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentari pored svake stranice i praćene izmene u PDF-ovima, otiscima i slikama; lista komentara na kraju veb-stranica, Markdown-a i teksta.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Ograničenja oblikovanja
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1413,6 +1413,8 @@
Formatted Форматирано
Interface size: Величина интерфејса:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно.
Print Markup Штампај ознаке
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари поред сваке странице и праћене измене у PDF-овима, отисцима и сликама; листа коментара на крају веб-страница, Markdown-а и текста.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Ограничења обликовања
Can't render this file because it contains an unexpected character in line 11 and column 25.
+2
View File
@@ -1408,6 +1408,8 @@
Left Only Yalnızca Sol
Right Only Yalnızca Sağ
Largest Only Yalnızca En Geniş
Print Markup İşaretlemeyi Yazdır
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi.
Recent Colors Son Kullanılan Renkler
More Colors… Diğer Renkler…
Standard Standart
Can't render this file because it contains an unexpected character in line 431 and column 13.
+2
View File
@@ -1411,6 +1411,8 @@
Formatted Відформатовано
Interface size: Розмір інтерфейсу:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо.
Print Markup Друкувати примітки та виправлення
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментарі біля кожної сторінки й відстежені зміни в PDF, друку та зображеннях; список коментарів наприкінці вебсторінок, Markdown і тексту.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Обмеження форматування
Can't render this file because it contains an unexpected character in line 6 and column 31.
+2
View File
@@ -1411,6 +1411,8 @@
Formatted 已设置格式
Interface size: 界面大小:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。
Print Markup 打印标记
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、打印和图像中于每页旁显示批注和修订;在网页、Markdown 和文本末尾附上批注列表。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 格式限制
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1400,6 +1400,8 @@
Formatted 已設定格式
Interface size: 介面大小:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。
Print Markup 列印標記
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、列印和影像中於每頁旁顯示註解和修訂;在網頁、Markdown 和文字末尾附上註解清單。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 格式設定限制
Can't render this file because it contains an unexpected character in line 554 and column 13.
+2 -1
View File
@@ -524,7 +524,8 @@ impl WordApp {
// Word documents are encrypted with the document's password, if it has one.
let password =
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password)?;
let opts = wordcraft_engine::cmd::file::export_options(&self.session, &params);
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word diagrams: theme-styled shapes and VML lines, #611; previously trivial: Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -23,7 +23,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. No writer |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). No PDF/A option, no "best for printing/online" choice |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86). No footnotes, comments, tracked changes, fields or RTL |
@@ -118,6 +118,7 @@ coverage and WordCraft layout matching remain separate gaps.
| 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) |
| 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) |
| 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) |
| 2026-10-11 | trivial | Print Markup: comments exported in PDFs, prints, page images and HTML/Markdown/text copies (#589) |
| 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) |
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
+2 -2
View File
@@ -2,11 +2,11 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**481 of 496 catalog features have commands (97.0%).**
**482 of 497 catalog features have commands (97.0%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| File | 21 | 21 |
| Quick Access | 4 | 4 |
| Home | 102 | 103 |
| Insert | 47 | 53 |
+1 -1
View File
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **481 / 496 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **482 / 497 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |