diff --git a/ROADMAP.md b/ROADMAP.md index fa868c983f..84735ddedd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 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. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. @@ -711,6 +712,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps |---|---|---| | 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-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) | | 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) | | 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | diff --git a/craft-i18n.rev b/craft-i18n.rev index b41e6cc739..a202e2ad59 100644 --- a/craft-i18n.rev +++ b/craft-i18n.rev @@ -1 +1 @@ -20467b63558f04fe075e83aaea501bd10f7fa684 +cde4900ea1e1a71b5c8d2b3c91519645fc1690a6 diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index e185aed1fe..cddb7581b7 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -206,6 +206,11 @@ pub(crate) struct Alphas { /// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to /// the bytes, as [`crate::LayoutCache`]'s outlines do. seen: HashMap<(usize, usize), Option>>, + /// Pictures whose cost was charged for a lasting outline found in [`crate::LayoutCache`] + /// ([`Alphas::charge`]) without decoding them in this layout. + charged: HashSet<(usize, usize)>, + /// Pixels each picture took of the budget (also when it then failed to decode), by bytes. + costs: HashMap<(usize, usize), u64>, /// Pictures refused for the budget (bytes' address and length), not looked at again. refused: HashSet<(usize, usize)>, /// Pixels decoded so far. @@ -223,9 +228,16 @@ impl Alphas { Some(alpha) => alpha.clone(), None if self.refused.contains(&key) => return Err(OverBudget), None => { - let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| { + // A picture already charged for (its outline was cached) isn't charged twice. + let mut free = 0u64; + let paid = self.charged.contains(&key); + let before = self.decoded; + let alpha = decode_alpha(bytes, if paid { &mut free } else { &mut self.decoded }).inspect_err(|_| { self.refused.insert(key); })?; + if !paid { + self.costs.insert(key, self.decoded.saturating_sub(before)); + } let alpha = alpha.map(Arc::new); self.seen.insert(key, alpha.clone()); alpha @@ -233,6 +245,41 @@ impl Alphas { }; Ok(alpha.and_then(|a| alpha_in_crop(&a, crop))) } + + /// The pixels picture `bytes` took of the budget when it was decoded in this layout (0: + /// not decoded, or none to look at; a picture that failed to decode still cost its pixels). + pub(crate) fn cost(&self, bytes: &[u8]) -> u64 { + self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0) + } + + /// Charges the budget for picture `bytes`, whose outline was found kept from an earlier + /// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline + /// by their place in the document, not by what the cache holds. [`OverBudget`] when it + /// wouldn't have fitted: its rectangle is its outline in this layout. + pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> { + let key = (bytes.as_ptr() as usize, bytes.len()); + if self.seen.contains_key(&key) || self.charged.contains(&key) { + return Ok(()); + } + if self.refused.contains(&key) { + return Err(OverBudget); + } + if cost == 0 { + return Ok(()); + } + match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) { + Some(total) => { + self.decoded = total; + self.charged.insert(key); + self.costs.insert(key, cost); + Ok(()) + } + None => { + self.refused.insert(key); + Err(OverBudget) + } + } + } } /// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures diff --git a/crates/layout/src/ctx.rs b/crates/layout/src/ctx.rs index 48107999cd..2a3f5739e8 100644 --- a/crates/layout/src/ctx.rs +++ b/crates/layout/src/ctx.rs @@ -69,6 +69,9 @@ pub(crate) struct Ctx<'a> { pub(crate) hf: Arc>, /// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]). pub(crate) alphas: contour::Alphas, + /// Items re-placed as repeated table header rows so far, in the whole layout (see + /// [`MAX_HEADER_ITEMS`]). + pub(crate) header_repeats: usize, } struct ParaEnvInput<'a> { @@ -144,10 +147,14 @@ pub(crate) fn with_pin( /// an address names the same bytes only while their allocation lives: the weak reference keeps /// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address /// and length and be taken for it. -pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak>, Option>>)>; +/// +/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout +/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]). +pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak>, Option>>, u64)>; /// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this /// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it. +/// One kept from before is charged to the budget as if decoded now. pub(crate) fn picture_outline( outlines: &mut Outlines, alphas: &mut contour::Alphas, @@ -155,14 +162,17 @@ pub(crate) fn picture_outline( bytes: &Arc>, crop: [f32; 4], ) -> Option>> { - if let Some((_, o)) = outlines.get(&key) { + if let Some((_, o, cost)) = outlines.get(&key) { + // Kept from an earlier layout, but it still costs this one's budget: the same pictures + // get outlines whatever the cache holds. + alphas.charge(bytes, *cost).ok()?; return o.clone(); } let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new); if outlines.len() >= 256 { outlines.clear(); } - outlines.insert(key, (Arc::downgrade(bytes), outline.clone())); + outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes))); outline } diff --git a/crates/layout/src/headers.rs b/crates/layout/src/headers.rs index e6c22dc061..d3af09bc28 100644 --- a/crates/layout/src/headers.rs +++ b/crates/layout/src/headers.rs @@ -109,11 +109,64 @@ impl Linked { /// Header rows are re-placed on every page a table spills onto, so a table is O(header items × /// pages) placed items — and an exact-height header row keeps all its overflowing text without /// growing, so a few KB of document could otherwise fill memory. Once this many items (a row -/// counts at least one) have been repeated across a table, later pages carry no header rows; +/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows; /// pagination still finishes. Far above any real table (three ordinary header rows over some /// 5 000 pages). pub(crate) const MAX_HEADER_ITEMS: usize = 500_000; +#[cfg(test)] +thread_local! { + /// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it. + pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell = const { std::cell::Cell::new(MAX_HEADER_ITEMS) }; +} + +/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests. +pub(crate) fn header_items_cap() -> usize { + #[cfg(test)] + return HEADER_ITEMS_CAP.with(|c| c.get()); + #[cfg(not(test))] + MAX_HEADER_ITEMS +} + +/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against +/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every +/// member below it, nested groups included, as placing clones them all. Counted with an explicit +/// stack, and counting stops once the total passes `limit` (the result is then just over it), so +/// neither deep nesting nor a huge group makes the count itself costly. +pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize { + placed_cost_by(it, limit, |_| 1) +} + +/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it. +pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize { + let mut count = 0usize; + let mut stack: Vec> = Vec::new(); + let mut next = Some(it); + loop { + let cur = match next.take() { + Some(c) => c, + None => loop { + match stack.last_mut() { + None => return count, + Some(iter) => match iter.next() { + Some(c) => break c, + None => { + stack.pop(); + } + }, + } + }, + }; + count = count.saturating_add(cost(cur)); + if count > limit { + return count; + } + if let Placed::Clip { items, .. } = cur { + stack.push(items.iter()); + } + } +} + /// Header tables can displace a non-overlapping body table. Page-positioned drawings keep /// their own wrapping rules, so only table areas are retained from the header layout. pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index d3873e1d69..68ebc375ac 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -66,6 +66,9 @@ pub use floats::{is_floating, shape_figure_alt}; use floats::{left_out_objects, wrap_area}; #[cfg(test)] use headers::MAX_HEADER_ITEMS; +#[cfg(test)] +use headers::placed_cost; +use headers::placed_cost_by; pub use notes::DECOR_STORY; pub(crate) use notes::NOTE_SEP_W; use notes::{MAX_NOTE_PASSES, footnote_pages}; diff --git a/crates/layout/src/pass.rs b/crates/layout/src/pass.rs index 4c78aff2a5..6d06423b1e 100644 --- a/crates/layout/src/pass.rs +++ b/crates/layout/src/pass.rs @@ -109,6 +109,7 @@ fn new_ctx<'a>( floats_above: HashMap::new(), hf: Arc::new(HfLinks::new(doc.sections())), alphas: Default::default(), + header_repeats: 0, } } @@ -560,6 +561,7 @@ struct Snap<'a> { eq_count: u32, page_field: u32, boxes: usize, + header_repeats: usize, } impl<'a> Snap<'a> { @@ -577,6 +579,7 @@ impl<'a> Snap<'a> { eq_count: ctx.eq_count, page_field: ctx.fields.page, boxes: ctx.boxes.total(), + header_repeats: ctx.header_repeats, } } @@ -599,6 +602,7 @@ impl<'a> Snap<'a> { ctx.counters = self.counters.clone(); ctx.eq_count = self.eq_count; ctx.fields.page = self.page_field; + ctx.header_repeats = self.header_repeats; } } diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index f866cd8bf8..d234168471 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box}; use super::ctx::{Ctx, with_pct_base}; use super::floats::{WrapArea, align_in, body_wraps}; use super::flow::again; -use super::headers::{MAX_HEADER_ITEMS, header_table_areas}; +use super::headers::{header_items_cap, header_table_areas, placed_cost}; use super::notes::table_notes; use super::page::{PageBuilder, PageFrame, page_frame}; use super::placed::Placed; @@ -127,10 +127,14 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc // A table in the text flow starts below the floating objects in its way. below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width); let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect(); - // Items repeated so far, and what one more repeat of the header rows costs (see - // `MAX_HEADER_ITEMS`). - let mut header_repeats = 0usize; - let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add); + // What one more repeat of the header rows costs (see `MAX_HEADER_ITEMS`; the items repeated + // so far are counted in the whole layout). + let header_cap = header_items_cap(); + let header_cost = header_rows + .iter() + .filter_map(|i| tl.rows.get(*i)) + .map(|r| r.items.iter().map(|it| placed_cost(it, header_cap)).fold(0, usize::saturating_add).max(1)) + .fold(0, usize::saturating_add); // Resolve kept chains once; nested paragraphs cannot keep their enclosing row. let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect(); for i in (0..tl.rows.len().saturating_sub(1)).rev() { @@ -180,7 +184,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc let table_at_top = pb.at_top(); for (ri, row) in tl.rows.iter().enumerate() { // Word never splits a row of an exact height: it moves on whole. - let splittable = !header_rows.contains(&ri) + let splittable = ri >= header_rows.len() && !t .rows .get(ri) @@ -237,9 +241,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc None => {} } pb.advance(block, body_top); - // Repeat header rows, up to this table's cap. - if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS { - header_repeats += header_cost; + // Repeat header rows, up to the layout's cap. + if ri >= header_rows.len() && ctx.header_repeats.saturating_add(header_cost) <= header_cap { + ctx.header_repeats = ctx.header_repeats.saturating_add(header_cost); for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) { place(pb, h); } diff --git a/crates/layout/src/tests/budget.rs b/crates/layout/src/tests/budget.rs index 52da0646a5..6c3e5e4094 100644 --- a/crates/layout/src/tests/budget.rs +++ b/crates/layout/src/tests/budget.rs @@ -1,6 +1,7 @@ //! Hostile documents whose layout work or memory would otherwise grow far past their size: each //! is bounded by a cap, and real documents stay well under it. +use crate::ctx::Outlines; use crate::*; use wordcraft_doc::props::HeightRule; use wordcraft_doc::table::{MAX_COLS, Table}; @@ -36,10 +37,11 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 { } #[test] -fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { +fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() { // Forty linked boxes, each a line deep and each a different width, over a long story: every - // width would lay the whole story out again. Past the cap, later boxes reuse the nearest - // width already laid out, so every box still shows its share of the story. + // width would lay the whole story out again. Past the work cap, later boxes reuse the + // nearest width already laid out, so every box still shows its share of the story. + textbox::FLOW_WORK_CAP.with(|c| c.set(2000)); let n = 40; let mut d = Document::from_text(&vec!["p"; n].join("\n")); let story = "Words that run on and on through every box of the chain. ".repeat(200); @@ -56,7 +58,85 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect(); widths.sort_unstable(); widths.dedup(); - assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); + textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK)); + // The cap is spent after the first few layouts (each is far over 2000 / 8 work). + assert!(widths.len() <= 8, "{} widths laid the story out", widths.len()); +} + +#[test] +fn twenty_distinct_widths_of_an_ordinary_story_all_keep_their_width() { + // A real chain of 20 boxes, 100, 105, ... 195 pt wide: each is laid out at its own width. + let n = 20; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 5.0 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), n, "every box has a layout at its own width"); +} + +/// How many times story layouts ran while `doc` was laid out with the chain work cap at `cap`. +fn chain_layouts(d: &Document, cap: usize) -> usize { + textbox::FLOW_WORK_CAP.with(|c| c.set(cap)); + textbox::FLOW_LAYOUTS.with(|c| c.set(0)); + lay(d); + textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK)); + textbox::FLOW_LAYOUTS.with(|c| c.get()) +} + +/// `n` linked boxes `w0`, `w0 + 0.1`, ... pt wide (each its own layout) and a line deep, the +/// first holding `blocks`; the first box's id. +fn hairline_chain(d: &mut Document, n: usize, w0: f32, blocks: Vec>) -> u32 { + let ids: Vec = (0..n).map(|i| text_box(d, i, "", w0 + 0.1 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + d.parts.get_mut(&ids[0]).unwrap().blocks = blocks; + ids[0] +} + +#[test] +fn a_chain_whose_story_shows_little_still_pays_for_its_text() { + // All 256 boxes a hair apart in width, each showing one short visible line of a story that + // hides about 1 MB of text between them: each layout gives some 300 lines but works through + // the whole story. Charged by its lines alone, every width was laid out (256 times the story). + let n = wordcraft_doc::MAX_LINKED_BOXES; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let hidden = "Hidden words that never show. ".repeat(3500 / 30); + let hide = wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() }; + let story = (0..300) + .map(|k| { + let shown = format!("Line {k}."); + let mut p = Paragraph::with_text(&format!("{shown}{hidden}"), Default::default()); + p.runs = vec![ + wordcraft_doc::para::Run { len: shown.len(), props: Default::default() }, + wordcraft_doc::para::Run { len: hidden.len(), props: hide.clone() }, + ]; + p.touch(); + para_block(p) + }) + .collect(); + hairline_chain(&mut d, n, 300.0, story); + // Ordinary stories of this many lines keep all 256 widths under this cap. + let layouts = chain_layouts(&d, 100_000); + assert!(layouts <= 8, "{layouts} layouts of a 1 MB story"); +} + +#[test] +fn a_chain_of_wide_boxes_pays_for_a_long_paragraph() { + // 256 wide boxes a hair apart over one long paragraph: few lines per layout, but each + // layout shapes all of it. + let n = wordcraft_doc::MAX_LINKED_BOXES; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(64 * 1024 / 58); + hairline_chain(&mut d, n, 1500.0, vec![para_block(Paragraph::with_text(&story, Default::default()))]); + let layouts = chain_layouts(&d, 2000); + assert!(layouts <= 4, "{layouts} layouts of a 64 KB story"); } /// The paragraph layouts (by address) showing story `head` across the document. @@ -95,7 +175,8 @@ fn stacked_boxes_of_any_width_share_a_layout() { fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() { // The cap is full of horizontal widths when a vertical box comes: it lays the story out // its own way rather than showing a horizontal layout. - let n = textbox::MAX_FLOW_WIDTHS + 1; + textbox::FLOW_WORK_CAP.with(|c| c.set(2000)); + let n = 17; let mut d = Document::from_text(&vec!["p"; n].join("\n")); let story = "Words that run on and on through every box of the chain. ".repeat(200); let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); @@ -109,7 +190,12 @@ fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() { assert_eq!(widths.len(), n, "every box shows the story"); widths.sort_unstable(); widths.dedup(); - assert_eq!(widths.len(), n, "the vertical box has a layout of its own"); + assert!(widths.len() > 1 && widths.len() < n, "{} layouts: the vertical box has its own, the rest are shared", widths.len()); + // The vertical box's layout is none of the horizontal boxes', still past the cap. + let mut all = story_layouts(&lay(&d), ids[0]); + textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK)); + let vertical = all.pop(); + assert!(vertical.is_some_and(|v| !all.contains(&v)), "the vertical box shares a horizontal layout"); } #[test] @@ -276,7 +362,7 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { assert!(outlines.is_empty(), "the refusal isn't kept"); let mut fresh = contour::Alphas::default(); assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some()); - assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept"); + assert!(outlines.get(&key).is_some_and(|(_, o, _)| o.is_some()), "the outline is kept"); } /// The outline cache outlives the documents it is used with and tells pictures apart by their @@ -288,9 +374,270 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() { let mut outlines = HashMap::new(); let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]); assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some()); - let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") }; + let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") }; assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes"); assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes"); drop(disc); assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept"); } + +/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing +/// the header clones every one of them: they all count against the repeat budget. +#[test] +fn a_clipped_group_costs_its_members_when_a_header_repeats() { + let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() }; + let clip = |items: Vec| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items }; + let big = usize::MAX; + assert_eq!(placed_cost(&rule(), big), 1); + assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001); + // Nested groups count too, however deep: every descendant is charged. + assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5); + let mut deep = clip((0..1000).map(|_| rule()).collect()); + for _ in 0..20 { + deep = clip(vec![deep]); + } + assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each"); + // Counting stops once past the limit, so a huge or very deep group is cheap to count. + assert_eq!(placed_cost(&deep, 10), 11); + let mut deeper = rule(); + for _ in 0..100_000 { + deeper = clip(vec![deeper]); + } + assert_eq!(placed_cost(&deeper, 50), 51); + assert_eq!(placed_cost(&deeper, big), 100_001); + std::mem::forget(deeper); +} + +/// The linked-box work budget counts a clipped group's members, nested groups included. +#[test] +fn flow_work_counts_clip_members() { + let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() }; + let clip = |items: Vec| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items }; + let mut deep = clip((0..100).map(|_| rule()).collect()); + for _ in 0..12 { + deep = clip(vec![deep, rule()]); + } + assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2); +} + +/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one. +#[test] +fn flow_work_counts_each_line_in_a_clip() { + let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200))); + let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap(); + let (l0, l1) = match &lines { + Placed::Lines { l0, l1, .. } => (*l0, *l1), + _ => (0, 0), + }; + assert!(l1 - l0 > 1); + let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] }; + assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0)); +} + +#[test] +fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() { + // Each table's repeats used to have a budget of their own: a hundred tables, a hundred times + // the cap. + let table = || { + let mut t = Table::new(201, 1, 468.0); + t.rows[0].props.header = true; + t.rows[0].props.height = Some(20.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect(); + for row in t.rows.iter_mut().skip(1) { + row.props.height = Some(600.0); + row.props.height_rule = HeightRule::Exact; + } + t + }; + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap(); + let l = lay(&d); + let items: usize = l.pages.iter().map(|p| p.items.len()).sum(); + assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed"); +} + +/// Which pictures wrap by their outline must not depend on what the cache already holds: a +/// picture's outline kept from an earlier layout is charged to the budget like a decoded one. +#[test] +fn cold_and_warm_caches_give_the_same_outlines() { + let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64))); + let key = |bytes: &Arc>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]); + // Room for one of the two pictures. + let pass = |outlines: &mut Outlines| { + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64; + let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some()); + (got, alphas.decoded) + }; + let mut roomy = contour::Alphas::default(); + let mut warm = Outlines::new(); + for (p, n) in [(&a, "a"), (&b, "b")] { + assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some()); + } + assert_eq!(warm.len(), 2, "both outlines are kept"); + let (cold, cold_spent) = pass(&mut Outlines::new()); + assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle"); + let (warmed, warm_spent) = pass(&mut warm); + assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm"); + assert_eq!(warm_spent, cold_spent); + // And again: a layout after a layout. + assert_eq!(pass(&mut warm).0, cold); +} + +/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency +/// to look at, costs its pixels, and then fails to decode. +fn truncated_png(side: u32) -> Vec { + fn crc(data: &[u8]) -> u32 { + let mut c = !0u32; + for b in data { + c ^= u32::from(*b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut ihdr = b"IHDR".to_vec(); + ihdr.extend(side.to_be_bytes()); + ihdr.extend(side.to_be_bytes()); + ihdr.extend([8, 6, 0, 0, 0]); + let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13]; + png.extend(&ihdr); + png.extend(crc(&ihdr).to_be_bytes()); + // An image data chunk of junk: the header is read, the pixels aren't there. + let mut idat = b"IDAT".to_vec(); + idat.extend([0x78, 0x9c, 1, 2, 3, 4]); + png.extend(6u32.to_be_bytes()); + png.extend(&idat); + png.extend(crc(&idat).to_be_bytes()); + png +} + +#[test] +fn a_truncated_picture_costs_the_same_cold_and_warm() { + // Its header promises transparency, so a layout spends its pixels before the body fails to + // decode; a layout finding its (empty) outline kept spends them too, or pictures after it + // would get outlines only with the cache warm. + let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64))); + let key = |bytes: &Arc>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]); + let pass = |outlines: &mut Outlines| { + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000; + let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some()); + (got, alphas.decoded) + }; + let mut warm = Outlines::new(); + let (cold, spent) = pass(&mut warm); + assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels"); + assert_eq!(cold, [false, false], "no budget is left for the disc"); + assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost"); + // Also with a roomy first layout filling the cache, then a tight one. + let mut roomy_cache = Outlines::new(); + let mut roomy = contour::Alphas::default(); + for (p, n) in [(&big, "big"), (&disc, "disc")] { + let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]); + } + assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold"); +} + +#[test] +fn a_header_row_holding_a_canvas_repeats_within_the_budget() { + use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap}; + // A canvas of 800 members in an exact-height header row, over two thousand pages: each page + // would clone all 800 members. + let member = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 4.0, + h: 4.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Float::default(), + story: None, + effects: Default::default(), + freeform: None, + extra: Default::default(), + }; + let canvas = InlineObject::Group { + w: 40.0, + h: 40.0, + float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() }, + ch_w: 40.0, + ch_h: 40.0, + children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(), + canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }), + }; + let mut t = Table::new(2001, 1, 468.0); + t.rows[0].props.header = true; + t.rows[0].props.height = Some(60.0); + t.rows[0].props.height_rule = HeightRule::Exact; + let mut p = Paragraph::with_text("", Default::default()); + p.insert_object(0, canvas, &Default::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![para_block(p)]; + for row in t.rows.iter_mut().skip(1) { + row.props.height = Some(600.0); + row.props.height_rule = HeightRule::Exact; + } + fn count(it: &Placed) -> usize { + match it { + Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add), + _ => 1, + } + } + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 1000, "{} pages", l.pages.len()); + let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum(); + assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed"); + assert!(items > 100_000, "the header still repeats on early pages: {items}"); +} + +/// A table with a repeating header row in a two-column section ended by a continuous break, so +/// its columns are balanced: laid out once, then again from a snapshot for each balancing try. +fn balanced_header_table(rows: usize) -> Document { + use wordcraft_doc::section::{SectionProps, SectionStart}; + let mut d = Document::from_text("Before\nEnd of the section\nAfter the break"); + let mut s = SectionProps::default(); + s.columns.count = 2; + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s)); + d.last_section.start = SectionStart::Continuous; + let mut t = Table::new(rows, 1, 200.0); + t.rows[0].props.header = true; + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + d +} + +/// Header rows placed (each repeat of the one-column header row is one cell of row 0). +fn header_cells(l: &DocLayout) -> usize { + l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count() +} + +fn with_header_cap(cap: usize, f: impl FnOnce() -> T) -> T { + headers::HEADER_ITEMS_CAP.with(|c| c.set(cap)); + let out = f(); + headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS)); + out +} + +#[test] +fn balancing_columns_gives_back_the_header_repeats_it_throws_away() { + // The table runs over both columns of a page and onto a second one. + let d = balanced_header_table(120); + let full = lay(&d); + let shown = header_cells(&full); + assert!(shown >= 3, "the header repeats in each later column: {shown} header rows"); + // What one repeat costs: the smallest cap at which a plain two-page table repeats its header. + let mut plain = Table::new(80, 1, 200.0); + plain.rows[0].props.header = true; + let mut p = Document::from_text(""); + p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap(); + let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items"); + // A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't + // spend it. + let capped = with_header_cap((shown - 1) * cost, || lay(&d)); + assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often"); +} diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 11534efb3d..72a9e849c5 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -208,13 +208,66 @@ struct Cut { byte: usize, } -/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole -/// story out again and is kept while the chain is split, so a chain of boxes that each differ in -/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't -/// among them takes the nearest narrower one laid out in its text direction, so its lines still -/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer -/// than the box. Far above a real chain's few box sizes. -pub(crate) const MAX_FLOW_WIDTHS: usize = 16; +/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length +/// (and text direction) lays the whole story out again and is kept while the chain is split, so +/// a chain of boxes that each differ in width multiplies its story's layout work and memory by +/// its widths. A layout costs the lines and other items it gives, or its story's text at +/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden, +/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the +/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and +/// the first in each other direction. Within this every box of an ordinary story (even all +/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't +/// among those laid out takes the nearest narrower one laid out in its text direction, so its +/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines +/// are longer than the box. Only a huge story gets there. +pub(crate) const MAX_FLOW_WORK: usize = 1_000_000; + +/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth. +pub(crate) const STORY_BYTES_PER_WORK: usize = 64; + +#[cfg(test)] +thread_local! { + /// A smaller cap for tests, so a short story can reach it. + pub(crate) static FLOW_WORK_CAP: std::cell::Cell = const { std::cell::Cell::new(MAX_FLOW_WORK) }; + /// How many times chains' stories have been laid out, for tests. + pub(crate) static FLOW_LAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn flow_work_cap() -> usize { + #[cfg(test)] + return FLOW_WORK_CAP.with(|c| c.get()); + #[cfg(not(test))] + MAX_FLOW_WORK +} + +/// What laying `items` out cost: its lines and other items (a clipped group's members, nested +/// ones too, each count, its lines each). +pub(crate) fn flow_work(items: &[Placed]) -> usize { + let cap = flow_work_cap(); + items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add) +} + +/// One for an item, or its lines for a run of lines. +fn line_count(it: &Placed) -> usize { + match it { + Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1), + _ => 1, + } +} + +/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's +/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`]. +fn story_work(blocks: &Blocks) -> usize { + let mut bytes = 0usize; + let mut stack: Vec<&Arc> = blocks.iter().collect(); + while let Some(b) = stack.pop() { + match &**b { + Block::Para(p) => bytes = bytes.saturating_add(p.text.len()), + Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)), + } + } + bytes / STORY_BYTES_PER_WORK +} /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart /// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size. @@ -237,6 +290,9 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let snap = (ctx.counters.clone(), ctx.eq_count); let mut after = None; let mut layouts: Vec<((i32, TextVert), Vec)> = Vec::new(); + // Work done by the layouts so far (see `MAX_FLOW_WORK`). + let mut work = 0usize; + let floor = story_work(&blocks); let mut out = Vec::with_capacity(chain.len()); // Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown. let mut start: Option> = Some(None); @@ -249,17 +305,20 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); let key = flow_key(len, body.vert); - // Past the cap the box takes the nearest layout in its direction: the widest line length + // Past the work cap the box takes the nearest layout in its direction: the widest line length // that fits it, else the narrowest. The first box in a direction is laid out (one more // layout for each of the few directions at most). let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0))); - let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None }); + let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None }); let items = match kept { Some((_, items)) => items.clone(), None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count)); + #[cfg(test)] + FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1))); + work = work.saturating_add(flow_work(&items).max(floor)); layouts.push((key, items.clone())); items } diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs index f569b9cf9c..9fccba415c 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs @@ -322,7 +322,7 @@ impl LanguageForm { /// A language's name in the interface language (the tag itself when we don't know it). pub(super) fn language_name(tag: &str) -> String { - tl!(&crate::ribbon::language_name(Some(tag))).to_string() + crate::ribbon::display_name(Some(tag)) } pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool { diff --git a/crates/ui-egui/src/language_packs.rs b/crates/ui-egui/src/language_packs.rs index 517f4ee2ae..64f9f6dffb 100644 --- a/crates/ui-egui/src/language_packs.rs +++ b/crates/ui-egui/src/language_packs.rs @@ -160,9 +160,8 @@ pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [ /// the Language dialog name it; else the pack's own name; else its locale. pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String { let tag = crate::ribbon::tag_for_pack(pack_locale); - let named = crate::ribbon::language_name(Some(&tag)); - if !named.eq_ignore_ascii_case(&tag) { - return tl!(&named).to_string(); + if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) { + return crate::ribbon::display_name(Some(&tag)); } if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ad727435df..2b7e2b7b64 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -57,7 +57,7 @@ pub(crate) use language::sort_key; pub(crate) use language::{ caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu, }; -pub(crate) use language::{language_list, language_name, same_language, tag_for_pack}; +pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack}; use layout::layout; #[cfg(test)] use layout::{columns_items, margins_items, orientation_items}; diff --git a/crates/ui-egui/src/ribbon/language.rs b/crates/ui-egui/src/ribbon/language.rs index 8815c0a89c..d2b482c2c5 100644 --- a/crates/ui-egui/src/ribbon/language.rs +++ b/crates/ui-egui/src/ribbon/language.rs @@ -96,7 +96,7 @@ pub(crate) fn short_languages() -> Vec<(String, String)> { more.push((tag, name)); } } - more.sort_by_cached_key(|(_, n)| sort_key(tl!(n))); + more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n))); out.extend(more); out } @@ -113,7 +113,7 @@ pub(crate) fn all_languages() -> Vec<(String, String)> { }); for (tag, name) in builtin.chain(packs) { if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) { - out.push((tag, tl!(&name).to_string())); + out.push((tag.clone(), display_of(&tag, &name))); } } out.sort_by_cached_key(|(_, n)| sort_key(n)); @@ -126,7 +126,7 @@ pub(crate) fn language_list(current: &str) -> Vec<(String, String)> { let mut list = all_languages(); // A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another. if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) { - list.push((current.to_string(), tl!(&language_name(Some(current))).to_string())); + list.push((current.to_string(), display_name(Some(current)))); list.sort_by_cached_key(|(_, n)| sort_key(n)); } list @@ -170,15 +170,53 @@ pub(crate) fn sort_key(name: &str) -> (String, String) { (folded, lower) } +/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`) +/// or a language with a region the lists have no entry for (`Swedish` and `Finland`). +enum NameParts { + Whole(String), + Regioned { language: String, region: String }, +} + /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's /// default, US English): a listed language by its name; another region of one by the language /// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag. pub(crate) fn language_name(tag: Option<&str>) -> String { + match name_parts(tag) { + NameParts::Whole(n) => n, + NameParts::Regioned { language, region } => format!("{language} ({region})"), + } +} + +/// [`language_name`] in the interface language: a whole name is looked up as it is, a language +/// with a region is the translated `{language} ({region})` of the translated language and region +/// (a name built at run time has no catalog row of its own). English is unchanged. +pub(crate) fn display_name(tag: Option<&str>) -> String { + display_name_with(tag, &|s| tl!(s).to_string()) +} + +/// [`display_name`] with the lookup given (`translate`: English in, interface language out). +pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String { + match name_parts(tag) { + NameParts::Whole(n) => translate(&n), + NameParts::Regioned { language, region } => { + crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(®ion))]) + } + } +} + +/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name +/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft +/// has none for). +pub(crate) fn display_of(tag: &str, name: &str) -> String { + if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() } +} + +fn name_parts(tag: Option<&str>) -> NameParts { let tag = tag.unwrap_or("en-US"); let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n); // Any language the Language dialog offers (French, German…) by its name, not its tag (#860). if let Some(name) = named(tag) { - return name.to_string(); + return NameParts::Whole(name.to_string()); } let mut parts = tag.split(['-', '_']); let primary = parts.next().unwrap_or(""); @@ -191,12 +229,12 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { .find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) .map(|(_, n)| n.split(" (").next().unwrap_or(n)); match language { - Some(language) => format!("{language} ({})", region_name(r)), - None => tag.to_string(), + Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) }, + None => NameParts::Whole(tag.to_string()), } } // A bare language subtag ("pt", "pl"): the variant WordCraft proofs. - None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { + None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), @@ -213,7 +251,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { _ => named_starting(tag) .or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag))) .map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()), - }, + }), } } @@ -295,7 +333,7 @@ fn region_name(code: &str) -> String { /// The language at the caret (resolved through the styles), translated, for the status bar. pub(crate) fn caret_language(app: &WordApp) -> String { - tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() + display_name(caret_lang_tag(app).as_deref()) } /// The formatting at the caret, resolved through the styles and the document default. @@ -349,11 +387,11 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); let entries = short_languages(); let checks = language_checks(¤t, &entries); - if let Some(name) = checks.extra { - mi_check(ui, app, &name, true, "review.language", json!({"lang": current})); + if checks.extra.is_some() { + mi_check(ui, app, &display_name(Some(¤t)), true, "review.language", json!({"lang": current})); } for ((tag, name), on) in entries.iter().zip(checks.listed) { - mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); + mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag})); } ui.separator(); mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index e1c4221495..9ddb1e3272 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -308,6 +308,32 @@ fn rows_are_matched_by_tag_not_by_display_name() { assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]); } +/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row +/// of its own, so the language, the region and the `{language} ({region})` template are looked up +/// separately and joined. English stays as `language_name` builds it. +#[test] +fn a_language_and_region_name_is_translated_in_parts() { + use super::language::display_name_with; + let fake = |s: &str| -> String { + match s { + "Swedish" => "X".into(), + "Finland" => "Y".into(), + "{language} ({region})" => "{language} \u{2013} {region}".into(), + other => other.to_string(), + } + }; + assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y"); + // A whole name is looked up as it is; the template isn't used for it. + assert_eq!(display_name_with(Some("sv-SE"), &fake), "X"); + // Without a catalogue (English) the name is byte-identical to the English name. + for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] { + assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}"); + } + assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)"); + // Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows. + assert!(!super::language::same_language("sv-FI", "sv-SE")); +} + /// #860 review: a tag the language list doesn't have shows by language and region, not as /// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 0d2510c252..cd558ef8bf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -71,7 +71,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | -| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once; language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | @@ -82,7 +82,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | +| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13); repeated table headers are capped by item counts, the wrap cache by decoded pixels and linked text box layout by its lines, items and story text, and PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866; not measured on real documents) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | @@ -155,6 +155,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 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 | | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0681502353..2eec1f25f3 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -278,6 +278,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e40323fa9e..d15c3f36c7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -268,6 +268,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review | | 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack | | 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open | | 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown | diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index 109d9ee1c5..9b9d48b390 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -126,6 +126,6 @@ modules: # the two equal). - type: git url: https://github.com/storytold/craft-i18n - commit: 20467b63558f04fe075e83aaea501bd10f7fa684 + commit: cde4900ea1e1a71b5c8d2b3c91519645fc1690a6 dest: craft-i18n - cargo-sources.json