mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 18:51:26 +00:00
Merge branch 'main' of https://github.com/storytold/wordcraft into pr-5559
# Conflicts: # ROADMAP.md # crates/formats/src/odt.rs # crates/formats/src/odt/body.rs # crates/formats/src/odt/import.rs # crates/formats/src/odt/page_parts.rs # docs/file-format-parity.md # docs/gaps.md
This commit is contained in:
commit
87cfbac0ee
39 files changed
+935
-128
No files matched your search
Generated
+1
@@ -7051,6 +7051,7 @@ dependencies = [
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
|
||||
@@ -180,6 +180,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-31: ODT: footnotes and endnotes open and save (they were dropped on open), as Word-style note stories with their custom marks (gap 19). Readiness unchanged.
|
||||
- 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged.
|
||||
- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged.
|
||||
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
|
||||
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
|
||||
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
|
||||
- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view.
|
||||
@@ -342,6 +343,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
|
||||
| 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. |
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows |
|
||||
| 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). |
|
||||
| 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes |
|
||||
| 2026-10-16 | English dialects (#1281, #1045): English (UK), (Australia), (Canada) and the other Englishes are proofed in their own spelling — *colour* is flagged in a US document and *color* in a British one, with the right form suggested first — from our own list of American/British spellings, so nothing to install. Review › Language lists them, the .docx `w:lang` keeps them, and an English interface on a British (or Canadian, Australian…) system starts new documents in that English |
|
||||
@@ -760,6 +762,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
|
||||
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
|
||||
| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
|
||||
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
|
||||
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -663,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
// The deepest nesting allowed, each table in the first cell of the one around it, ahead of
|
||||
// that cell's paragraph: the innermost paragraphs come first in the document but the
|
||||
// paragraph walk stops short.
|
||||
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
for _ in 1..MAX_TABLE_DEPTH {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
@@ -71,10 +71,12 @@ pub struct Table {
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
|
||||
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
|
||||
/// when it is saved, copied or dropped (#1596).
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
|
||||
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
|
||||
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
|
||||
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
|
||||
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
|
||||
pub const MAX_TABLE_DEPTH: usize = 18;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
|
||||
|
||||
use super::references::{FieldCode, leader};
|
||||
use super::references::{FieldCode, field_arg, leader};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
@@ -44,11 +44,12 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
|
||||
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
|
||||
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
|
||||
instr.push_str(&format!(" \\t \"{see}\""));
|
||||
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
|
||||
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
|
||||
}
|
||||
instr.push_str(&format!(" \\r {}", bm.trim()));
|
||||
// A name read from a file may hold a space, a quote or a backslash.
|
||||
instr.push_str(&format!(" \\r {}", field_arg(bm)));
|
||||
}
|
||||
if p::bool(v, "bold").unwrap_or(false) {
|
||||
instr.push_str(" \\b");
|
||||
@@ -393,6 +394,36 @@ mod tests {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
|
||||
}
|
||||
|
||||
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
|
||||
/// can hold) is quoted in the XE field, so it reads back whole.
|
||||
#[test]
|
||||
fn mark_entry_trims_and_quotes_its_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
|
||||
s.run("select.text", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
|
||||
let odd = "My \"Mark\" \\b";
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
|
||||
let mut instrs: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
instrs.sort();
|
||||
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
|
||||
let beta = FieldCode::parse(&instrs[1]);
|
||||
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
|
||||
}
|
||||
|
||||
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
|
||||
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
|
||||
/// before right-aligned page numbers, and two columns in a continuous section.
|
||||
|
||||
@@ -137,7 +137,8 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
|
||||
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
|
||||
/// writes as `null` and can't read back.
|
||||
#[test]
|
||||
fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
|
||||
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
|
||||
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
|
||||
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
|
||||
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
|
||||
}
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
|
||||
@@ -7,6 +7,7 @@ use wordcraft_doc::props::{Align, CharProps};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::references::{field_arg, field_words, quoted};
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -122,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
|
||||
let f = p::req_str(v, "field")?.to_string();
|
||||
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
|
||||
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
|
||||
})
|
||||
.params(r#"{"field": string}"#)
|
||||
.key(super::non_mac("Alt+Shift+F")),
|
||||
@@ -136,14 +137,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
|
||||
let instr = match rule.as_str() {
|
||||
"IF" => format!(
|
||||
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
|
||||
quote(p::str(v, "field").unwrap_or("Field")),
|
||||
p::str(v, "value").unwrap_or(""),
|
||||
p::str(v, "then").unwrap_or(""),
|
||||
p::str(v, "else").unwrap_or("")
|
||||
"IF {{ MERGEFIELD {} }} = {} {} {}",
|
||||
field_arg(p::str(v, "field").unwrap_or("Field")),
|
||||
quoted(p::str(v, "value").unwrap_or("")),
|
||||
quoted(p::str(v, "then").unwrap_or("")),
|
||||
quoted(p::str(v, "else").unwrap_or(""))
|
||||
),
|
||||
"SKIPIF" | "NEXTIF" => {
|
||||
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
|
||||
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
|
||||
}
|
||||
"MERGEREC" => "MERGEREC".into(),
|
||||
_ => "NEXT".into(),
|
||||
@@ -227,10 +228,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn quote(f: &str) -> String {
|
||||
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
|
||||
json, p, para_block,
|
||||
field_words, json, p, para_block,
|
||||
};
|
||||
|
||||
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
@@ -41,9 +41,7 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
|
||||
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.trim();
|
||||
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
|
||||
Some(name.to_string())
|
||||
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Which columns hold address parts.
|
||||
@@ -103,17 +101,17 @@ fn rule_field(t: &str) -> Option<String> {
|
||||
|
||||
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
|
||||
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
|
||||
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
|
||||
let field = rule_field(t).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
let cmp = quoted.next().unwrap_or("");
|
||||
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
|
||||
let cmp = texts.next().unwrap_or_default();
|
||||
let v = value(s, row, &field);
|
||||
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
|
||||
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => a.partial_cmp(&b),
|
||||
_ => Some(v.as_str().cmp(cmp)),
|
||||
_ => Some(v.as_str().cmp(cmp.as_str())),
|
||||
};
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let holds = match op.as_str() {
|
||||
@@ -124,7 +122,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
">=" => matches!(ord, Some(Greater | Equal)),
|
||||
_ => ord == Some(Equal),
|
||||
};
|
||||
(holds, quoted.collect())
|
||||
(holds, texts.collect())
|
||||
}
|
||||
|
||||
/// The first word of a field instruction, upper-cased.
|
||||
@@ -149,7 +147,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
|
||||
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
|
||||
"IF" if rule_field(t).is_some() => {
|
||||
let (holds, texts) = condition(s, t, row);
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -307,6 +307,29 @@ fn if_rule_previews_its_result() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
|
||||
}
|
||||
|
||||
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
|
||||
#[test]
|
||||
fn rule_texts_keep_their_quotes() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
|
||||
s.run("mailings.rules", &rule).unwrap();
|
||||
s.run("mailings.preview", &json!({"value": true})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
|
||||
s.run("mailings.next", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
|
||||
// SKIPIF compares with the value as typed.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
|
||||
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
|
||||
}
|
||||
|
||||
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
|
||||
/// own header.
|
||||
#[test]
|
||||
|
||||
@@ -110,37 +110,61 @@ pub struct FieldCode {
|
||||
pub switches: Vec<(char, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]).
|
||||
pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// `s` as quoted field-code text, which [`field_words`] reads back as it was.
|
||||
pub fn quoted(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '"' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`].
|
||||
pub fn field_arg(s: &str) -> String {
|
||||
if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() }
|
||||
}
|
||||
|
||||
impl FieldCode {
|
||||
pub fn parse(instr: &str) -> FieldCode {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
let mut words = words.into_iter().peekable();
|
||||
let mut words = field_words(instr).into_iter().peekable();
|
||||
let mut f =
|
||||
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
|
||||
while let Some((w, quoted)) = words.next() {
|
||||
|
||||
@@ -2570,19 +2570,22 @@ fn select_extend_from_a_table_paragraph_selects_the_document() {
|
||||
assert_eq!(b, s.doc.end_of(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table
|
||||
/// in the deepest cell says so; a table pasted there brings its text as paragraphs.
|
||||
/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot)
|
||||
/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text
|
||||
/// as paragraphs.
|
||||
#[test]
|
||||
fn tables_nest_at_most_24_deep() {
|
||||
fn tables_nest_only_as_deep_as_a_json_save_reads() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err();
|
||||
assert!(e.to_string().contains("24 deep"), "{e}");
|
||||
assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "edit.paste", json!({"text": "b", "html": "<table><tr><td>b</td></tr></table>"}));
|
||||
let cell = s.sel.focus.path.clone();
|
||||
@@ -2591,6 +2594,44 @@ fn tables_nest_at_most_24_deep() {
|
||||
assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there");
|
||||
}
|
||||
|
||||
/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads
|
||||
/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and
|
||||
/// gradient text effects changed with Track Changes on (the change keeps the old effects too).
|
||||
#[test]
|
||||
fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "word"}));
|
||||
run(&mut s, "select.text", json!({"text": "word"}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": true}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": false}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "in box"}));
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_ne!(s.sel.focus.story, StoryRef::Body);
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
// Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did.
|
||||
let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap();
|
||||
let gradient = |f: &Option<wordcraft_doc::fill::Fill>| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_)));
|
||||
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}");
|
||||
assert!(
|
||||
para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())),
|
||||
"{para:?}"
|
||||
);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
}
|
||||
|
||||
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
|
||||
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
|
||||
#[test]
|
||||
@@ -4593,13 +4634,41 @@ fn hostile_params_never_panic() {
|
||||
for j in &junk {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
|
||||
let _ = s.run(spec.id, j);
|
||||
let r = s.run(spec.id, j);
|
||||
assert!(!caught_panic(®, &r), "{} panicked on {j}: {r:?}", spec.id);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
|
||||
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
|
||||
/// those words.
|
||||
fn caught_panic(reg: &crate::Registry, r: &Result<serde_json::Value, crate::CmdError>) -> bool {
|
||||
matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_panic_flags_a_command_that_panics() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure();
|
||||
// Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207).
|
||||
let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure();
|
||||
s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer]));
|
||||
let boom = s.run("test.boom", &json!({}));
|
||||
assert!(caught_panic(&s.registry, &boom));
|
||||
let nested = s.run("test.outer", &json!({}));
|
||||
assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}");
|
||||
assert!(caught_panic(&s.registry, &nested));
|
||||
assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into()))));
|
||||
// A command's own error can quote what it was given.
|
||||
let mut full = Session::new(crate::sample::sample_document());
|
||||
let echoed = full.run("developer.control", &json!({"control": "x panicked: y"}));
|
||||
assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}");
|
||||
assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_structure() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
|
||||
@@ -56,9 +56,10 @@ pub fn notes(doc: &Document) -> Vec<Note> {
|
||||
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() {
|
||||
let mut objects = p.objects.iter();
|
||||
for c in p.text.chars() {
|
||||
if c == OBJ {
|
||||
match p.object_at(off) {
|
||||
match objects.next() {
|
||||
Some(InlineObject::CommentStart { id }) => {
|
||||
if seen.insert(*id) {
|
||||
order.push(*id);
|
||||
@@ -198,3 +199,6 @@ fn text(notes: &[Note]) -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::{Block, CharProps, Comment, Paragraph, PartKind, Run, Table, para_block};
|
||||
|
||||
fn paragraph(text: String, objects: Vec<InlineObject>) -> Paragraph {
|
||||
let len = text.len();
|
||||
Paragraph { text, objects, runs: vec![Run { len, props: CharProps::default() }], ..Paragraph::new() }
|
||||
}
|
||||
|
||||
fn comment(doc: &mut Document, id: u32, author: &str, parent: Option<u32>, text: &str) {
|
||||
let part = doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
doc.comments.insert(id, Comment { author: author.into(), part, parent, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_walk_preserves_comment_quotes_order_and_unpaired_objects() {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, " Ada ", None, "First note");
|
||||
comment(&mut doc, 3, " ", Some(9), "Reply text");
|
||||
comment(&mut doc, 5, "", None, "Second note");
|
||||
comment(&mut doc, 7, "Grace", None, "Unanchored");
|
||||
comment(&mut doc, 12, "Linus", None, "Cell note");
|
||||
doc.body = vec![para_block(paragraph(
|
||||
format!("é漢{OBJ}α {OBJ}β{OBJ} γ{OBJ}{OBJ}"),
|
||||
vec![
|
||||
InlineObject::CommentStart { id: 9 },
|
||||
InlineObject::CommentStart { id: 5 },
|
||||
InlineObject::CommentEnd { id: 9 },
|
||||
InlineObject::CommentEnd { id: 5 },
|
||||
// No marker for this object: it must not anchor the unanchored comment.
|
||||
InlineObject::BookmarkStart { name: "unrelated".into() },
|
||||
InlineObject::CommentStart { id: 7 },
|
||||
],
|
||||
))];
|
||||
// Missing object storage must not replay an earlier comment marker.
|
||||
doc.body.push(para_block(paragraph(format!("{OBJ}{OBJ}tail"), vec![])));
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks =
|
||||
vec![para_block(paragraph(format!("{OBJ}cell{OBJ}"), vec![InlineObject::CommentStart { id: 12 }, InlineObject::CommentEnd { id: 12 }]))];
|
||||
doc.body.push(std::sync::Arc::new(Block::Table(table)));
|
||||
let before = doc.clone();
|
||||
let actual = notes(&doc);
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![
|
||||
Note { author: "Ada".into(), quote: "α β".into(), text: "First note".into(), replies: vec![("Author".into(), "Reply text".into())] },
|
||||
Note { author: "Author".into(), quote: "β γ".into(), text: "Second note".into(), replies: vec![] },
|
||||
Note { author: "Linus".into(), quote: "cell".into(), text: "Cell note".into(), replies: vec![] },
|
||||
Note { author: "Grace".into(), quote: "".into(), text: "Unanchored".into(), replies: vec![] },
|
||||
]
|
||||
);
|
||||
assert_eq!(doc, before, "export discovery must leave the document unchanged");
|
||||
}
|
||||
|
||||
fn dense_markers(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, "Ada", None, "Kept note");
|
||||
let mut objects = Vec::with_capacity(2 * n + 2);
|
||||
for k in 0..n {
|
||||
objects.push(InlineObject::BookmarkStart { name: format!("bookmark{k}") });
|
||||
objects.push(InlineObject::BookmarkEnd { name: format!("bookmark{k}") });
|
||||
}
|
||||
objects.extend([InlineObject::CommentStart { id: 9 }, InlineObject::CommentEnd { id: 9 }]);
|
||||
doc.body = vec![para_block(paragraph(format!("{}{OBJ}é漢 quote{OBJ}", OBJ.to_string().repeat(2 * n)), objects))];
|
||||
doc
|
||||
}
|
||||
|
||||
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
|
||||
// performance evidence; the semantic assertions below still run on every platform.
|
||||
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
|
||||
#[test]
|
||||
fn comment_export_discovery_scales_with_unrelated_markers() {
|
||||
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
|
||||
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
|
||||
assert_eq!(notes(&small), expected);
|
||||
assert_eq!(notes(&big), expected);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(notes(&small));
|
||||
}
|
||||
});
|
||||
let b = cpu_cost(|| {
|
||||
std::hint::black_box(notes(&big));
|
||||
});
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
|
||||
} else {
|
||||
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,7 @@ pub use export::{esc, export, export_flow, export_fragment, inlines_html};
|
||||
use import::parse_in;
|
||||
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
|
||||
|
||||
#[cfg(test)]
|
||||
use css::css_unescape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -46,6 +46,48 @@ pub(super) fn strip_important(v: &str) -> &str {
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
|
||||
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
|
||||
/// form feed count as one newline, as CSS reads them (§3.3).
|
||||
pub(super) fn css_unescape(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
|
||||
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
|
||||
Some('\r') => {
|
||||
it.next();
|
||||
it.next_if_eq(&'\n');
|
||||
}
|
||||
Some(' ' | '\t' | '\n' | '\u{C}') => {
|
||||
it.next();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
o.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
|
||||
hex.extend(it.next());
|
||||
}
|
||||
if hex.is_empty() {
|
||||
// Before a newline it continues the line; at the end it is dropped.
|
||||
match it.peek() {
|
||||
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
|
||||
Some(_) => o.extend(it.next()),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skip_space(&mut it);
|
||||
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
|
||||
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Apply a `style` attribute to formatting / block properties.
|
||||
pub(super) fn apply_style(
|
||||
css: &str,
|
||||
@@ -88,7 +130,7 @@ pub(super) fn apply_style(
|
||||
}
|
||||
}
|
||||
"font-family" => {
|
||||
let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string();
|
||||
let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
|
||||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
|
||||
@@ -19,6 +19,24 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
|
||||
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
|
||||
/// these are written as hex escapes ([`css_unescape`] reads them back).
|
||||
fn css_font_name(name: &str) -> String {
|
||||
let mut o = String::with_capacity(name.len() + 2);
|
||||
o.push('\'');
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'\0' => o.push('\u{FFFD}'),
|
||||
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('\'');
|
||||
o
|
||||
}
|
||||
|
||||
fn align_css(a: Option<Align>) -> Option<&'static str> {
|
||||
match a? {
|
||||
Align::Center => Some("text-align:center"),
|
||||
@@ -92,7 +110,7 @@ fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||||
css.push(format!("font-family:{}", esc(&css_font_name(fam))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
|
||||
@@ -314,6 +314,44 @@ fn leading_space_survives_round_trip() {
|
||||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_escapes_read_every_kind_of_newline() {
|
||||
// A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one
|
||||
// of them (CR LF as one) ends the escape.
|
||||
assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE");
|
||||
assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD");
|
||||
assert_eq!(css_unescape("x\\'y\\"), "x'y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_are_written_as_css_strings() {
|
||||
// A font name could end the CSS string or the declaration and add CSS of its own (#3739).
|
||||
let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"];
|
||||
let mut p = Para::default();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() });
|
||||
}
|
||||
let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }));
|
||||
assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}");
|
||||
let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect();
|
||||
assert_eq!(styles.len(), fonts.len(), "{html}");
|
||||
for s in styles {
|
||||
assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}");
|
||||
}
|
||||
// Read back, each run has its font as it was.
|
||||
let back = parse(&html);
|
||||
let runs: Vec<(String, Option<String>)> = back
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None })
|
||||
.collect();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||||
let props = wordcraft_doc::CharProps::default();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::table::{MAX_COLS, MAX_ROWS};
|
||||
use wordcraft_doc::{Align, Document};
|
||||
|
||||
use crate::html::decode_entity;
|
||||
@@ -21,6 +22,11 @@ mod blocks;
|
||||
mod export;
|
||||
mod inlines;
|
||||
|
||||
/// Table cells a Markdown document makes in all, header rows included (hostile input). A short
|
||||
/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column
|
||||
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
|
||||
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
|
||||
|
||||
pub use blocks::{Refs, import, import_on, parse};
|
||||
pub(crate) use export::escape;
|
||||
pub use export::export;
|
||||
@@ -28,5 +34,7 @@ pub use export::export;
|
||||
use export::{escape_line_start, inlines_md, table_md, write_blocks};
|
||||
pub use inlines::parse_inlines;
|
||||
|
||||
#[cfg(test)]
|
||||
use blocks::split_row;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::inlines::parse_inlines;
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, Para, model};
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model};
|
||||
|
||||
/// Nesting limit for block containers (quotes, list items) and inline links.
|
||||
pub(super) const MAX_NEST: usize = 32;
|
||||
@@ -143,7 +143,8 @@ pub fn parse(text: &str) -> model::Flow {
|
||||
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
|
||||
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
|
||||
let refs = take_definitions(&mut lines);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs);
|
||||
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
|
||||
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
@@ -271,47 +272,69 @@ fn list_marker(t: &str) -> Option<Marker> {
|
||||
if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None }
|
||||
}
|
||||
|
||||
fn split_row(l: &str) -> Vec<String> {
|
||||
/// A table row without its leading and trailing pipes (an escaped last pipe stays).
|
||||
fn row_body(l: &str) -> &str {
|
||||
let t = l.trim();
|
||||
let t = t.strip_prefix('|').unwrap_or(t);
|
||||
let t = if let Some(before) = t.strip_suffix('|') {
|
||||
if let Some(before) = t.strip_suffix('|') {
|
||||
let num_bs = before.chars().rev().take_while(|&c| c == '\\').count();
|
||||
if num_bs % 2 == 0 { before } else { t }
|
||||
} else {
|
||||
t
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted.
|
||||
pub(super) fn split_row(l: &str, keep: usize) -> (Vec<String>, usize) {
|
||||
let mut cells = Vec::new();
|
||||
let mut count = 0usize;
|
||||
let mut cur = String::new();
|
||||
let mut chars = t.chars().peekable();
|
||||
let mut chars = row_body(l).chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
let full = cells.len() >= keep;
|
||||
match c {
|
||||
'\\' => match chars.peek() {
|
||||
Some(&'|') => {
|
||||
cur.push('|');
|
||||
if !full {
|
||||
cur.push('|');
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
Some(&'\\') => {
|
||||
cur.push('\\');
|
||||
cur.push('\\');
|
||||
if !full {
|
||||
cur.push_str("\\\\");
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
_ => cur.push('\\'),
|
||||
_ if !full => cur.push('\\'),
|
||||
_ => {}
|
||||
},
|
||||
'|' => cells.push(std::mem::take(&mut cur).trim().to_string()),
|
||||
_ => cur.push(c),
|
||||
'|' => {
|
||||
if !full {
|
||||
cells.push(std::mem::take(&mut cur).trim().to_string());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
_ if !full => cur.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cells.push(cur.trim().to_string());
|
||||
cells
|
||||
if cells.len() < keep {
|
||||
cells.push(cur.trim().to_string());
|
||||
}
|
||||
(cells, count + 1)
|
||||
}
|
||||
|
||||
fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
|
||||
/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every
|
||||
/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row.
|
||||
fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
|
||||
if !l.contains('-') {
|
||||
return None;
|
||||
}
|
||||
let cells = split_row(l);
|
||||
let mut out = Vec::new();
|
||||
for c in cells {
|
||||
let mut count = 0usize;
|
||||
// A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one.
|
||||
for c in row_body(l).split('|') {
|
||||
let c = c.trim();
|
||||
let left = c.starts_with(':');
|
||||
let right = c.ends_with(':');
|
||||
@@ -319,14 +342,17 @@ fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
|
||||
if core.is_empty() || !core.chars().all(|x| x == '-') {
|
||||
return None;
|
||||
}
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
if out.len() < keep {
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Some(out)
|
||||
Some((out, count))
|
||||
}
|
||||
|
||||
/// Does this line start a block that interrupts a paragraph?
|
||||
@@ -386,7 +412,8 @@ fn code_para(line: &str) -> FBlock {
|
||||
}
|
||||
|
||||
/// Parse block structure. `level` is the list nesting level for lists found here.
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<FBlock> {
|
||||
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
|
||||
let mut out = Vec::new();
|
||||
let mut para: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -460,32 +487,52 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
|
||||
}
|
||||
if t.starts_with('>') {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
parse_quote(lines, &mut i, depth, level, refs, &mut out);
|
||||
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
|
||||
continue;
|
||||
}
|
||||
if para.is_empty()
|
||||
&& t.contains('|')
|
||||
&& let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l))
|
||||
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
|
||||
{
|
||||
let head = split_row(t);
|
||||
if head.len() == aligns.len() {
|
||||
let (head, head_width) = split_row(t, MAX_COLS);
|
||||
if head_width == width {
|
||||
let mut table = FTable::default();
|
||||
let cell = |s: &str, header: bool, a: Option<Align>| {
|
||||
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
|
||||
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
|
||||
};
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
// The document keeps no more columns or rows than this, nor cells past its budget,
|
||||
// so those aren't built; their lines are still the table's.
|
||||
let cols = aligns.len();
|
||||
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
|
||||
let mut cut = head_width > cols;
|
||||
if fits(0) {
|
||||
budget.set(budget.get() - cols);
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 2;
|
||||
while let Some(l) = lines.get(i) {
|
||||
if is_blank(l) || !l.contains('|') || interrupts(l) {
|
||||
break;
|
||||
}
|
||||
let mut cells = split_row(l);
|
||||
cells.resize(aligns.len(), String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
if !table.rows.is_empty() && fits(table.rows.len()) {
|
||||
budget.set(budget.get() - cols);
|
||||
let (mut cells, _) = split_row(l, cols);
|
||||
cells.resize(cols, String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.push(FBlock::Table(table));
|
||||
if cut {
|
||||
model::dropped(model::Dropped::TableCells);
|
||||
}
|
||||
if !table.rows.is_empty() {
|
||||
out.push(FBlock::Table(table));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -493,7 +540,7 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
|
||||
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
|
||||
{
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
|
||||
continue;
|
||||
}
|
||||
para.push(line.clone());
|
||||
@@ -521,7 +568,7 @@ fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
|
||||
out.extend(code.into_iter().map(code_para));
|
||||
}
|
||||
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, out: &mut Vec<FBlock>) {
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
|
||||
let mut inner = Vec::new();
|
||||
while let Some(line) = lines.get(*i) {
|
||||
let trimmed = line.trim_start_matches([' ', '\t']);
|
||||
@@ -541,7 +588,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
|
||||
out.push(FBlock::Para(p));
|
||||
return;
|
||||
}
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs) {
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
|
||||
if let FBlock::Para(p) = &mut block
|
||||
&& p.kind == Kind::Normal
|
||||
{
|
||||
@@ -579,7 +626,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
|
||||
}
|
||||
|
||||
/// Parse a list starting at `lines[i]`; returns the index after it.
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs) -> usize {
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
|
||||
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
|
||||
while let Some(line) = lines.get(i) {
|
||||
let ind = indent(line);
|
||||
@@ -626,7 +673,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
p.push_text(&item.join(" "), &Fmt::default());
|
||||
vec![FBlock::Para(p)]
|
||||
} else {
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs)
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
|
||||
};
|
||||
let mut placed = false;
|
||||
if inner.is_empty() {
|
||||
|
||||
@@ -356,6 +356,67 @@ fn three_column_table_with_distinctive_text() {
|
||||
assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cells_past_the_document_limits_are_not_built() {
|
||||
// Every body row was padded to the header's width before any cap (#3701).
|
||||
let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&wide);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(t.rows.iter().all(|r| r.len() == MAX_COLS));
|
||||
assert_eq!(t.rows[2][0].text(), "b");
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the columns left out are logged");
|
||||
// Rows past the limit still belong to the table, so the text after it reads as before.
|
||||
let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&tall);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), MAX_ROWS);
|
||||
let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") };
|
||||
assert_eq!(p.text(), "after");
|
||||
assert_eq!(flow.blocks.len(), 2);
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the rows left out are logged");
|
||||
}
|
||||
|
||||
/// A short row is padded to its header's width, so a small file of one-cell rows under wide
|
||||
/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell
|
||||
/// budget: rows past it are left out and logged, and their lines make no paragraphs.
|
||||
#[test]
|
||||
fn tables_share_one_cell_budget() {
|
||||
let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|"));
|
||||
let fits = crate::markdown::MAX_TABLE_CELLS / MAX_COLS;
|
||||
let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&src);
|
||||
let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect();
|
||||
let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum();
|
||||
assert!(cells <= crate::markdown::MAX_TABLE_CELLS, "{cells} cells");
|
||||
assert_eq!(tables.iter().map(|t| t.rows.len()).collect::<Vec<_>>(), [fits], "the second table is past the budget");
|
||||
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["after"]);
|
||||
assert!(crate::model::Dropped::TableCells.reported());
|
||||
// Within the budget a table is whole and nothing is logged.
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&format!("{}| 1 | 2 |\n", header(2)));
|
||||
let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) };
|
||||
assert_eq!(t.rows.len(), 2);
|
||||
assert!(!crate::model::Dropped::TableCells.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_table_still_checks_every_header_and_delimiter_cell() {
|
||||
// Only the first 63 cells are kept, but every one is counted and checked.
|
||||
let head = "|a".repeat(100) + "|\n";
|
||||
let delim = |bad: Option<usize>| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::<String>() + "|\n";
|
||||
let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_)));
|
||||
assert!(is_table(format!("{head}{}| b |\n", delim(None))));
|
||||
assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones");
|
||||
assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells");
|
||||
let (cells, n) = split_row(r"| a | b \| c | d |", 2);
|
||||
assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_table_cells_containing_escaped_pipes() {
|
||||
let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n";
|
||||
|
||||
@@ -57,6 +57,10 @@ pub(crate) enum Dropped {
|
||||
RunStrings,
|
||||
/// A style name cut to [`MAX_FONT_NAME`].
|
||||
StyleName,
|
||||
/// ODT `text:s` spaces past the document's budget.
|
||||
Spaces,
|
||||
/// Markdown table rows and columns past the table limits or the document's cell budget.
|
||||
TableCells,
|
||||
}
|
||||
|
||||
impl Dropped {
|
||||
@@ -68,8 +72,23 @@ impl Dropped {
|
||||
format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them")
|
||||
}
|
||||
Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"),
|
||||
Dropped::Spaces => {
|
||||
"import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string()
|
||||
}
|
||||
Dropped::TableCells => format!(
|
||||
"import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out",
|
||||
wordcraft_doc::table::MAX_COLS,
|
||||
wordcraft_doc::table::MAX_ROWS,
|
||||
crate::markdown::MAX_TABLE_CELLS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this thread's import has logged this kind ([`dropped`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reported(self) -> bool {
|
||||
REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log that the importer left something of `kind` out: once per kind per imported document
|
||||
|
||||
@@ -69,7 +69,7 @@ use import::Pages;
|
||||
use import::{Pictures, apply_page_layout, read_entry, read_meta};
|
||||
pub use import::{import, import_on, import_with, parse};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_XML_DEPTH;
|
||||
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
|
||||
pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template};
|
||||
use styles::Styles;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::import::Pages;
|
||||
use super::limits::{MAX_COMMENTS, MAX_FIELD_RESULT, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
|
||||
use super::limits::{
|
||||
MAX_COMMENTS, MAX_EXTRA_SPACES, MAX_FIELD_RESULT, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH,
|
||||
};
|
||||
use super::styles::Styles;
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{
|
||||
@@ -80,6 +82,8 @@ pub(super) struct Body<'a> {
|
||||
para: Option<Para>,
|
||||
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
|
||||
xml_space_end: bool,
|
||||
/// What is left of [`MAX_EXTRA_SPACES`].
|
||||
pub(super) spaces_left: usize,
|
||||
para_cont: bool,
|
||||
/// Open lists: (style name, item open, item used).
|
||||
lists: Vec<(Option<String>, bool)>,
|
||||
@@ -231,6 +235,7 @@ impl<'a> Body<'a> {
|
||||
fmt: Vec::new(),
|
||||
para: None,
|
||||
xml_space_end: false,
|
||||
spaces_left: MAX_EXTRA_SPACES,
|
||||
para_cont: false,
|
||||
lists: Vec::new(),
|
||||
lists_saved: Vec::new(),
|
||||
@@ -1539,9 +1544,16 @@ impl Body<'_> {
|
||||
}
|
||||
self.fmt.push(fmt);
|
||||
}
|
||||
"s" => {
|
||||
// Outside a paragraph `push_text` drops spaces, so they cost no budget.
|
||||
"s" if self.para.is_some() => {
|
||||
let count = get(a, "text:c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
self.push_text(&" ".repeat(count));
|
||||
let want = count.saturating_sub(1);
|
||||
let extra = want.min(self.spaces_left);
|
||||
if extra < want {
|
||||
model::dropped(model::Dropped::Spaces);
|
||||
}
|
||||
self.spaces_left -= extra;
|
||||
self.push_text(&" ".repeat(1 + extra));
|
||||
}
|
||||
"tab" => self.push_text("\t"),
|
||||
"line-break" => self.push_text("\n"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::body::Body;
|
||||
use super::limits::{MAX_ENTRY, MAX_SECTIONS};
|
||||
use super::limits::{MAX_ENTRY, MAX_EXTRA_SPACES, MAX_SECTIONS};
|
||||
use super::styles::{Styles, column_sep, columns_of};
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts};
|
||||
@@ -773,11 +773,15 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
|
||||
// The page background picture's bytes.
|
||||
let page_image = pages.doc.page_image.clone().and_then(|(href, fill)| fetch(&href).map(|b| (b, fill)));
|
||||
let objects = (entries.as_slice(), &manifest);
|
||||
let mut parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, objects)).unwrap_or_default();
|
||||
// One `text:s` budget for the headers and footers and then the body.
|
||||
let mut spaces_left = MAX_EXTRA_SPACES;
|
||||
let mut parts =
|
||||
styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, objects, &mut spaces_left)).unwrap_or_default();
|
||||
first_pages(&mut pages);
|
||||
let mut body = Body::new(&styles, &pages, &mut fetch);
|
||||
(body.entries, body.manifest) = (objects.0, Some(objects.1));
|
||||
body.raw = &content;
|
||||
body.spaces_left = spaces_left;
|
||||
super::xml::events_at(&content, |ev, at| body.feed_at(ev, at));
|
||||
let mut blocks = body.take_blocks();
|
||||
// The file's own definitions: what kept paragraphs refer to, and the style definitions
|
||||
|
||||
@@ -21,3 +21,5 @@ pub(super) const MAX_OPEN_INSERTIONS: usize = 64;
|
||||
|
||||
/// Longest change author or date kept, in characters.
|
||||
pub(super) const MAX_INFO_CHARS: usize = 256;
|
||||
/// Most spaces the document's `text:s` elements add beyond the one each stands for (hostile input).
|
||||
pub(super) const MAX_EXTRA_SPACES: usize = 4 << 20;
|
||||
@@ -69,12 +69,15 @@ impl Parts {
|
||||
/// page's section as part id 1, 2, … in order, so the sections the body starts carry them.
|
||||
/// [`add`] puts those parts into the document.
|
||||
/// `objects`: the package's entry names and manifest media types, for embedded objects.
|
||||
/// `spaces_left` is the document's [`MAX_EXTRA_SPACES`] budget, which the body read after them
|
||||
/// goes on with.
|
||||
pub(super) fn read(
|
||||
xml: &[u8],
|
||||
styles: &Styles,
|
||||
pages: &mut Pages,
|
||||
fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>,
|
||||
objects: (&[String], &std::collections::HashMap<String, String>),
|
||||
spaces_left: &mut usize,
|
||||
) -> Parts {
|
||||
let mut found: Vec<(String, Slot, Vec<Ev>)> = Vec::new();
|
||||
let mut master: Option<String> = None;
|
||||
@@ -126,9 +129,11 @@ pub(super) fn read(
|
||||
let mut reader = Body::new(styles, &none, fetch);
|
||||
reader.in_text = true;
|
||||
(reader.entries, reader.manifest) = (objects.0, Some(objects.1));
|
||||
reader.spaces_left = *spaces_left;
|
||||
for ev in evs {
|
||||
reader.feed(ev);
|
||||
}
|
||||
*spaces_left = reader.spaces_left;
|
||||
out.watermark = out.watermark.take().or(reader.watermark.take());
|
||||
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
|
||||
if let Some(sec) = pages.masters.get_mut(&name) {
|
||||
|
||||
@@ -522,6 +522,24 @@ fn deeply_nested_header_content_does_not_panic() {
|
||||
assert_eq!(part_text(&d, d.last_section.footers.default), "After");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_footers_share_the_text_s_budget_with_the_body() {
|
||||
// Each header and footer was read with a budget of its own (#3700).
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(3_000);
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>h{run}</text:p></style:header><style:footer><text:p>f{run}</text:p></style:footer></style:master-page></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>b{run}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let texts =
|
||||
[part_text(&d, d.last_section.headers.default), part_text(&d, d.last_section.footers.default), d.plain_text(wordcraft_doc::StoryRef::Body)];
|
||||
// One space per element in all three, and one budget of extra spaces between them.
|
||||
assert_eq!(texts.iter().map(|t| t.matches(' ').count()).sum::<usize>(), 3 * 3_000 + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("h ") && texts[1].starts_with("f ") && texts[2].starts_with("b "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_and_column_sections_become_sections() {
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
@@ -1083,6 +1101,38 @@ fn explicit_spaces_at_paragraph_ends_are_kept() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_spaces_have_a_budget_for_the_whole_document() {
|
||||
// 5,000 elements asking for 1,000 spaces each would add 5 million (#3700).
|
||||
let tags = 5_000;
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(tags);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>a{run}b</text:p><text:p>c<text:s text:c="3"/>d</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
// Once the budget is spent, each element still stands for one space, and the cut is logged.
|
||||
assert_eq!(texts[0].len(), 2 + tags + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("a ") && texts[0].ends_with(" b"));
|
||||
assert_eq!(texts[1], "c d");
|
||||
assert!(crate::model::Dropped::Spaces.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_outside_a_paragraph_spends_no_budget() {
|
||||
// Spaces outside a paragraph are dropped, so they mustn't use up the budget (#3700).
|
||||
let stray = r#"<text:s text:c="1000"/>"#.repeat(5_000);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text>{stray}<text:p>a<text:s text:c="1000"/>b</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
assert_eq!(texts, vec![format!("a{}b", " ".repeat(1000))]);
|
||||
assert!(!crate::model::Dropped::Spaces.reported(), "nothing was cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asian_complex_and_extended_text_properties() {
|
||||
let content = format!(
|
||||
|
||||
@@ -33,6 +33,12 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// [`esc`] for a `\fonttbl` name: `;` would end the entry, so it is written `\'3b`, and control
|
||||
/// characters, which `esc` writes as `\line` or `\tab`, are dropped.
|
||||
fn esc_font(name: &str) -> String {
|
||||
esc(&name.replace(char::is_control, "")).replace(';', "\\'3b")
|
||||
}
|
||||
|
||||
pub(super) struct Tables {
|
||||
pub(super) fonts: Vec<String>,
|
||||
pub(super) colors: Vec<Rgb>,
|
||||
@@ -697,7 +703,7 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl");
|
||||
for (i, f) in w.t.fonts.iter().enumerate() {
|
||||
let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" };
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc(f)));
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f)));
|
||||
}
|
||||
out.push_str("}\n{\\colortbl;");
|
||||
for c in &w.t.colors {
|
||||
|
||||
@@ -1229,6 +1229,40 @@ fn a_code_paragraph_round_trips_without_a_direct_code_font() {
|
||||
assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}");
|
||||
}
|
||||
|
||||
/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862).
|
||||
#[test]
|
||||
fn escaped_semicolons_in_a_font_name_stop_at_the_limit() {
|
||||
let mut r = reader::Reader::default();
|
||||
r.st.dest = state::Dest::FontTbl;
|
||||
for _ in 0..100_000 {
|
||||
r.ch(';');
|
||||
}
|
||||
assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1);
|
||||
let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1));
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
assert_eq!(run_fmt(&runs, "x").font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_semicolon_in_a_font_name_round_trips() {
|
||||
// `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740).
|
||||
let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")];
|
||||
let mut p = Para::default();
|
||||
for (t, font) in fonts {
|
||||
p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() });
|
||||
}
|
||||
let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() };
|
||||
let rtf = export(&model::to_doc(&flow));
|
||||
assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}");
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
for (t, font) in fonts {
|
||||
assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}");
|
||||
}
|
||||
// A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it.
|
||||
let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}");
|
||||
assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stylesheet_entry_without_a_number_is_normal() {
|
||||
// An entry without `\sN` is style 0; it doesn't replace the entry before it.
|
||||
|
||||
@@ -11,7 +11,7 @@ fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding>
|
||||
|
||||
impl Reader {
|
||||
pub(super) fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
let name = std::mem::take(&mut self.font_name).trim().to_string();
|
||||
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
|
||||
&& self.fonts.len() < 4096
|
||||
{
|
||||
@@ -36,6 +36,11 @@ impl Reader {
|
||||
self.skip_chars -= 1;
|
||||
return;
|
||||
}
|
||||
// `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`).
|
||||
if b == b';' && self.st.dest == Dest::FontTbl {
|
||||
self.ch(';');
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
let Some(encoding) = multibyte_encoding(cp) else {
|
||||
self.finish_multibyte();
|
||||
@@ -106,10 +111,11 @@ impl Reader {
|
||||
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
|
||||
/// BOM and the rest decoded as UTF-8.
|
||||
pub(super) fn run(&mut self, chunk: &[u8]) {
|
||||
if self.skip_chars > 0 {
|
||||
for b in chunk {
|
||||
self.byte(*b);
|
||||
}
|
||||
// The fallback bytes after `\uN` are skipped; the rest of the run is text like any other.
|
||||
let skip = self.skip_chars.min(chunk.len());
|
||||
self.skip_chars -= skip;
|
||||
let chunk = chunk.get(skip..).unwrap_or_default();
|
||||
if chunk.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
@@ -302,6 +308,14 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
self.finish_multibyte();
|
||||
// Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it.
|
||||
if c == ';' && self.st.dest == Dest::FontTbl {
|
||||
// Held to the limit as other characters are; one past it drops the name.
|
||||
if self.font_name.len() <= model::MAX_FONT_NAME {
|
||||
self.font_name.push(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
self.text(c.encode_utf8(&mut buf));
|
||||
}
|
||||
|
||||
+35
-5
@@ -1393,6 +1393,35 @@ fn bookmark_destination_is_the_page_of_its_start() {
|
||||
assert!(parse_iso(" 2024-05-06T07:08:09Z \n").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_and_mail_links_become_uri_actions() {
|
||||
// A script, file, data or UNC target exports as plain text, as in the other exporters (#3717).
|
||||
let links = [
|
||||
("web", "https://example.com/ok"),
|
||||
("mail", "mailto:a@example.com"),
|
||||
("script", "javascript:alert(1)"),
|
||||
("file", "file:///etc/passwd"),
|
||||
("data", "data:text/html,x"),
|
||||
("unc", r"\\host\share"),
|
||||
];
|
||||
let mut p = Paragraph::with_text("Links:", CharProps::default());
|
||||
for (text, link) in links {
|
||||
let n = p.len();
|
||||
p.insert_text(n, &format!(" {text}"), &CharProps { link: Some(link.into()), ..Default::default() }).unwrap();
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap()).into_owned();
|
||||
// `/URI (target)` per link (an action also says `/S /URI`).
|
||||
assert_eq!(raw.matches("/URI (").count() + raw.matches("/URI(").count(), 2);
|
||||
assert!(raw.contains("example.com/ok") && raw.contains("a@example.com"));
|
||||
for bad in ["javascript", "passwd", "text/html", "share"] {
|
||||
assert!(!raw.contains(bad), "{bad}");
|
||||
}
|
||||
let all = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
|
||||
assert!(all.contains("Links: web mail script file data unc"), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_fields_export_cached_links() {
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
@@ -2433,12 +2462,13 @@ fn only_safe_link_targets_become_annotations() {
|
||||
use hayro_syntax::object::{Array, Dict, String as PdfString};
|
||||
|
||||
let mut d = Document::new();
|
||||
d.body = ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd"]
|
||||
.into_iter()
|
||||
.map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() })))
|
||||
.collect();
|
||||
d.body =
|
||||
["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd", "data:text/html,hi", r"\\server\share\f.doc"]
|
||||
.into_iter()
|
||||
.map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() })))
|
||||
.collect();
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 4, "unlinked text stays");
|
||||
assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 6, "unlinked text stays");
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let uris: Vec<String> = pdf.pages()[0]
|
||||
.raw()
|
||||
|
||||
@@ -61,6 +61,9 @@ wordcraft-engine = { workspace = true, features = ["test-support"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
zip = { workspace = true }
|
||||
# #2726's regression test: the consumer the Windows adapter runs, fed with our updates.
|
||||
# Pinned to the version egui already brings into the tree, so nothing is built twice.
|
||||
accesskit_consumer = "=0.39.1"
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -75,6 +75,39 @@ pub struct Tree {
|
||||
pub selection: Option<TextSelection>,
|
||||
}
|
||||
|
||||
/// #2726: AccessKit's contract is that every update's focus is in the node list, and
|
||||
/// `accesskit_consumer` treats a breach as fatal: `validate_global` panics on the UI thread,
|
||||
/// which closes the window (the Windows adapter reports `Focused ID #… is not in the node list`).
|
||||
/// egui can send an update that drops a focused widget's node, and a screen reader's focus
|
||||
/// request targets nodes in this document description, which the list carries only while the
|
||||
/// layout, the pages on screen and the selection are the same as when it asked. This guard runs
|
||||
/// on every update egui produces, before it reaches the adapter: a focus the update does not
|
||||
/// carry falls back to the root, so the reader starts from the top of the document instead of
|
||||
/// the process dying.
|
||||
pub struct FocusGuard;
|
||||
|
||||
impl egui::plugin::Plugin for FocusGuard {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"a11y focus guard"
|
||||
}
|
||||
|
||||
fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) {
|
||||
if let Some(update) = &mut output.platform_output.accesskit_update {
|
||||
guard_update(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the focus at the root when this update's node list does not contain it.
|
||||
fn guard_update(update: &mut egui::accesskit::TreeUpdate) {
|
||||
if update.nodes.iter().any(|(id, _)| *id == update.focus) {
|
||||
return;
|
||||
}
|
||||
if let Some(root) = update.tree.as_ref().map(|t| t.root) {
|
||||
update.focus = root;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages to describe: those meeting `view`, and one either side.
|
||||
pub fn page_window(rects: &[Rect], view: Rect) -> Option<(usize, usize)> {
|
||||
let first = rects.iter().position(|r| r.intersects(view))?;
|
||||
@@ -782,6 +815,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #2726: the Windows adapter's consumer dies on an update whose focus is not in the node
|
||||
/// list (`Focused ID #… is not in the node list`). A drop like that is fatal: `Tree::new`
|
||||
/// panics. The guard moves the focus to the root, and the same update is accepted.
|
||||
#[test]
|
||||
fn a_focus_the_update_does_not_carry_falls_back_to_the_root() {
|
||||
let root = egui::accesskit_root_id().accesskit_id();
|
||||
let lost = egui::Id::unique("run_that_was_dropped").accesskit_id();
|
||||
let make = |focus: accesskit::NodeId| accesskit::TreeUpdate {
|
||||
nodes: vec![
|
||||
(root, {
|
||||
let mut n = Node::new(Role::Window);
|
||||
n.push_child(egui::Id::unique("kept").accesskit_id());
|
||||
n
|
||||
}),
|
||||
(egui::Id::unique("kept").accesskit_id(), Node::new(Role::TextRun)),
|
||||
],
|
||||
tree: Some(accesskit::TreeInfo::new(root)),
|
||||
tree_id: accesskit::TreeId::ROOT,
|
||||
focus,
|
||||
};
|
||||
// Red without the guard: the raw update breaks the consumer's contract.
|
||||
let raw = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| accesskit_consumer::Tree::new(make(lost), true)));
|
||||
assert!(raw.is_err(), "the update that drops the focused node must break the consumer");
|
||||
let mut fixed = make(lost);
|
||||
guard_update(&mut fixed);
|
||||
assert_eq!(fixed.focus, root);
|
||||
assert!(
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| accesskit_consumer::Tree::new(fixed, true))).is_ok(),
|
||||
"the guarded update is accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every update the app sends — idle frames, typing, scrolling, selection, dialogs — feeds
|
||||
/// the real Windows consumer without breaking its contract: its focus is in the node list.
|
||||
#[test]
|
||||
fn every_update_the_app_sends_is_valid_for_the_consumer() {
|
||||
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
app.run("text.insert", json!({"text": "Hello word"})).unwrap();
|
||||
app.run("para.bullets", json!({})).unwrap();
|
||||
app.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap();
|
||||
app.run("text.insert", json!({"text": "Cell"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut handler = NoopHandler;
|
||||
let mut tree: Option<accesskit_consumer::Tree> = None;
|
||||
for frame in 0..10 {
|
||||
let mut input = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||||
time: Some(frame as f64 * 0.1),
|
||||
..Default::default()
|
||||
};
|
||||
if frame == 5 {
|
||||
// A screen reader asks to focus a text run deep in the document description.
|
||||
let (doc, _) = app.canvas.widget_id.expect("the canvas widget");
|
||||
input.events.push(egui::Event::AccessKitActionRequest(accesskit::ActionRequest {
|
||||
action: accesskit::Action::Focus,
|
||||
target_node: doc.with(("para", [0u32])).with(("run", 0usize, 0usize)).accesskit_id(),
|
||||
target_tree: accesskit::TreeId::ROOT,
|
||||
data: None,
|
||||
}));
|
||||
}
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let Some(update) = out.platform_output.accesskit_update else { continue };
|
||||
tree = Some(match tree {
|
||||
None => accesskit_consumer::Tree::new(update, true),
|
||||
Some(mut t) => {
|
||||
t.update_and_process_changes(update, &mut handler);
|
||||
t
|
||||
}
|
||||
});
|
||||
}
|
||||
let tree = tree.expect("the app sends AccessKit updates");
|
||||
// The consumer's focus is one of its nodes.
|
||||
let focus = tree.state().focus_id_in_tree();
|
||||
assert!(tree.state().has_node(focus), "the consumer's focus is in the node list");
|
||||
}
|
||||
|
||||
struct NoopHandler;
|
||||
|
||||
impl accesskit_consumer::TreeChangeHandler for NoopHandler {
|
||||
fn node_added(&mut self, _node: &accesskit_consumer::NodeRef<'_>) {}
|
||||
fn node_updated(&mut self, _old_node: &accesskit_consumer::NodeRef<'_>, _new_node: &accesskit_consumer::NodeRef<'_>) {}
|
||||
fn focus_moved(&mut self, _old_node: Option<&accesskit_consumer::NodeRef<'_>>, _new_node: Option<&accesskit_consumer::NodeRef<'_>>) {}
|
||||
fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef<'_>) {}
|
||||
}
|
||||
|
||||
/// Hostile layouts and junk geometry don't panic and the tree stays bounded.
|
||||
#[test]
|
||||
fn junk_geometry_is_harmless() {
|
||||
|
||||
@@ -223,6 +223,9 @@ impl WordApp {
|
||||
|
||||
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
// #2726: egui's AccessKit update must carry the focused node in its node list; a frame
|
||||
// that drops it panics in `accesskit_consumer` and closes the window (see `a11y::FocusGuard`).
|
||||
ctx.add_plugin(super::a11y::FocusGuard);
|
||||
updates_ui::tick(self, ctx);
|
||||
crate::language_packs::tick(self);
|
||||
let lang = i18n::Lang::from_pref(&self.ui.language);
|
||||
|
||||
@@ -12,6 +12,8 @@ gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked
|
||||
files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is
|
||||
occasional.
|
||||
|
||||
Print Markup HTML, Markdown and text copies collect comment anchors with one forward object walk (#3614). Unicode quotes, tables, overlapping comment ranges, replies and unmatched object storage retain their behavior. Quote limits, body-only anchor traversal and readiness estimates are unchanged.
|
||||
|
||||
## Drawing payloads
|
||||
|
||||
DOCX textboxes retain their own frame and story when they contain an inline picture.
|
||||
@@ -407,6 +409,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
| 2026-10-31 | trivial | ODT: footnotes and endnotes (`text:note`, ODF 1.3 §6.3) open as note stories built as Word's (Footnote Text or Endnote Text paragraphs starting with the note's mark, the reference in the reference style, a `text:label` custom mark kept) and are written back with `text:note-citation`s numbered as LibreOffice shows them (footnotes 1, 2, 3; endnotes i, ii, iii); before, notes were dropped on open. `formats::odt::tests_more::notes` |
|
||||
| 2026-10-31 | trivial | ODT: headers and footers are written to each section's master page (every, left and first page), with Word's header and footer distances converted to ODF's header and footer bands and back on open; text fields (`text:page-number`, `text:page-count`, `text:date`, `text:time`, document information, `text:bookmark-ref`) open as WordCraft fields and are written back as ODF fields. `formats::odt::tests_more::{headers, fields}` |
|
||||
| 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` |
|
||||
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
|
||||
| 2026-11-03 | trivial | Replace Table of Contents keeps Word's title above the section break over entries in columns, and Remove takes the title, the blank paragraph and its break with the table, as observed in Word for Mac (#4041) |
|
||||
| 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists |
|
||||
| 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `<hr>` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule |
|
||||
|
||||
@@ -47,6 +47,8 @@ Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existin
|
||||
|
||||
Document model hangs (#3614, #3692): paragraph object walks, image deduplication, linear-equation letter runs and text box paste no longer do quadratic work or unbounded copies on hostile files. This narrows gap #3; the engine, DOCX reader and UI loops named in #3614 remain.
|
||||
|
||||
Print Markup text-copy exports also walk paragraph objects once when collecting comment quotes (#3614). Body/table traversal, quote limits, reply order and exported text remain unchanged. Other engine, reader and UI scans remain separate.
|
||||
|
||||
Active footnote and endnote text stays readable while editing (#2545). This fixes a display-context defect; the broader fidelity, accessibility and readiness gates below are unchanged.
|
||||
|
||||
Russian language-aware sorting now keeps ё with е instead of after я, with the diaeresis as a secondary difference. Composed and decomposed ё and й compare equally. Fixed synthetic collation tests cover order, case and index headings; this does not establish full Cyrillic or CLDR collation parity.
|
||||
@@ -160,6 +162,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
| 2026-11-04 | trivial | #19: ODT element audit; files from other applications checked |
|
||||
| 2026-11-01 | trivial | #19 narrowed: ODT read and written nearly whole on this branch (styles, tables, notes, comments, tracked changes including formatting, fields, indexes and marks, drawings, charts, embedded objects, forms, ruby, password protection, flat ODT), checked against LibreOffice; what's left listed; estimate 18–27 h → 10–16 h |
|
||||
| 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged |
|
||||
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
|
||||
| 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) |
|
||||
| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open |
|
||||
| 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Tables row: Table Properties applies to the selected rows, columns and cells, #4857; no percentage change); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (Tables row: tables nest 18 deep at most, so a .json save reads back, #3618); previously trivial (Tables row: Table Properties applies to the selected rows, columns and cells, #4857; no percentage change); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
@@ -62,7 +62,7 @@ full parity for the area.
|
||||
| Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288), Enclose Characters (#297). Kerning for fonts in the Font dialog. Missing: Asian Typography, ligature/stylistic-set options in the Font dialog's Advanced tab. |
|
||||
| Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. |
|
||||
| Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. |
|
||||
| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 24 deep at most, as the .docx reader and writer keep them (#1596; Word has no limit). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). |
|
||||
| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 18 deep at most, so a .json save or AutoRecover snapshot reads back even with rich content in the deepest cell (#1596, #3618; the .docx reader and writer keep 24; Word has no limit). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). |
|
||||
| Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). |
|
||||
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
|
||||
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
@@ -253,6 +253,7 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | trivial | Tables row: tables nest 18 deep at most, so a `.json` save or AutoRecover snapshot reads back, rich content in the deepest cell included (it was 24, which a `.json` save could not reopen); the .docx reader and writer still keep 24 (#3618); no percentage change |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
| 2026-10-18 | trivial | References: Insert Table of Figures dialog; a table of figures lists any caption label (`TOC \c`), without label and number (`\a`), without page numbers (`\n`) or with them after the text (`\p`); Word's switch order is recognised and the leader kept on update (#3013); no percentage change |
|
||||
| 2026-10-15 | trivial | Pictures, shapes, text boxes: Insert › Shapes adds the new shape beside the selection, as Word draws it, so a second shape no longer replaces the first (still selected after its insert) and selected text stays; a picture still replaces a selected picture, as in Word (#2019) |
|
||||
|
||||
Reference in new issue
Block a user