mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 08:41:10 +00:00
Merge upstream/main into feat/paragraph-tools-5043
This commit is contained in:
commit
2113f52db4
14 files changed
+569
-37
No files matched your search
@@ -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.
|
||||
@@ -708,6 +709,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Home › Paragraph Tools (#5043): nine selection- or document-scoped conversion and cleanup commands; one undo step per command, with tracked deletions preserved; readiness estimates unchanged |
|
||||
| 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) |
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
20467b63558f04fe075e83aaea501bd10f7fa684
|
||||
cde4900ea1e1a71b5c8d2b3c91519645fc1690a6
|
||||
@@ -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<Arc<Alpha>>>,
|
||||
/// 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
|
||||
|
||||
@@ -69,6 +69,9 @@ pub(crate) struct Ctx<'a> {
|
||||
pub(crate) hf: Arc<HfLinks<'a>>,
|
||||
/// 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<T>(
|
||||
/// 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<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
|
||||
///
|
||||
/// 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<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, 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<Vec<u8>>,
|
||||
crop: [f32; 4],
|
||||
) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> = 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<std::slice::Iter<Placed>> = 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<Rect> {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> = (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);
|
||||
}
|
||||
|
||||
@@ -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<u32> = (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<Arc<Block>>) -> u32 {
|
||||
let ids: Vec<u32> = (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<u32> = (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>| 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>| 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<Vec<u8>>, 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<u8> {
|
||||
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<Vec<u8>>, 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<T>(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");
|
||||
}
|
||||
@@ -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<usize> = 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<usize> = 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<Block>> = 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<Vec<Placed>> {
|
||||
let snap = (ctx.counters.clone(), ctx.eq_count);
|
||||
let mut after = None;
|
||||
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = 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<Option<Cut>> = Some(None);
|
||||
@@ -249,17 +305,20 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
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
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** 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 numberedLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** 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 offLine truncated
|
||||
> **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 lonLine truncated
|
||||
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).
|
||||
@@ -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: language-packs-2 published and pinned, 75 packs; Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto available as Language packs |
|
||||
| 2026-11-02 | trivial | #8 (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available as Language packs; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded; no ranked gap changed |
|
||||
|
||||
@@ -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 tLine truncated
|
||||
> **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 miLine truncated
|
||||
|
||||
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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** 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 (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
|
||||
@@ -256,6 +256,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 packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user