diff --git a/Cargo.lock b/Cargo.lock index cd0ff683a..538b4542f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5327,6 +5327,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", + "wordcraft-metafile", "wordcraft-proof", ] diff --git a/ROADMAP.md b/ROADMAP.md index e5c28e551..7aa12aa84 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,6 +143,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-21: Screen readers: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172). The boxes announced themselves only as "edit" and the arrows beside them only as "button". - 2026-10-20: A click below a floating table (one dragged by its move handle, or from a .docx) goes to the paragraph beside or after it, as in Word, instead of a cell of its last row, so the text after a moved table can be reached with the mouse again (#2038). +- 2026-10-20: A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF: a header bar cropped to 2.7% of a page-sized WMF is no longer half as tall, so the name in the text box over it shows (#1899). - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. - 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). @@ -152,6 +153,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | Personal templates (#2852): File › New lists the `.dotx` and `.dotm` files in your templates folder under Personal (a subfolder per category; the search box covers them), and one opens as a new untitled document attached to it, as in Word. Save As › Save as Word Template starts in that folder, made on first use in the settings folder; File › Options › Advanced › File locations › User templates points it elsewhere. Agents list them with `file.listTemplates`. | | 2026-10-19 | Delete with whole table cells selected (rows, columns, the table or one cell) empties each cell to one paragraph, as in Word: no blank paragraphs are left behind, the paragraph keeps the formatting of the cell's last one, and tables nested in the cells go too. One undo brings everything back (#2802). | | 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) | +| 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. | | 2026-10-19 | Typing on Linux no longer stops after clicking Search (#2986): Search, Go To's page box, the Icons search and a new caption label asked for the keyboard on every frame, and each request reset the input method; they now ask only while they lack it. | | 2026-10-19 | An equation from LibreOffice in an `.odt` file no longer shows blank (#3099): it's embedded as a `draw:object` formula sub-document holding MathML, with a `draw:image` replacement preview in LibreOffice's own metafile format WordCraft can't decode as a picture; the formula's own MathML is read instead, through the same reader as HTML equations. | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | @@ -162,11 +164,14 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | | 2026-10-16 | English dialects (#1281, #1045): English (UK), (Australia), (Canada) and the other Englishes are proofed in their own spelling — *colour* is flagged in a US document and *color* in a British one, with the right form suggested first — from our own list of American/British spellings, so nothing to install. Review › Language lists them, the .docx `w:lang` keeps them, and an English interface on a British (or Canadian, Australian…) system starts new documents in that English | | 2026-10-15 | Triple borders (tables, paragraphs, the `###` AutoFormat line) draw as three lines on screen and in PDF, across the line for vertical borders too; they used to draw as double lines (#1740) | +| 2026-10-16 | Pictures and shapes without text stories turn visibly while dragging the rotation handle (#2277), with Shift snapping to 15° steps; the document changes only on release through one `arrange.rotation` command, and Escape cancels the preview | | 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard | +| 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | | 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. | +| 2026-10-18 | Dragging a single selected cell's border (Select Cell, left-edge arrow, triple-click on its last paragraph) moves it in its row only; the neighbour takes up the difference. Checked against Word for Mac 16.113.4. | | 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 | | 2026-10-17 | Restrict Editing holds for structure too (#1327): an exception inside a table cell no longer lets table, row or cell formatting change the protected rest of the table; formatting limited to styles refuses Paste Formatting, table shading, borders, cell alignment, cell margins, text direction, table-style edits and locked table styles; loading, filtering or sorting recipients no longer rewrites merge-field results in a protected document. | | 2026-10-18 | Manage Sources › Import… (#2640) reads a Word bibliography file (`b:Sources`: Word's own `Sources.xml` master list) into the document: the tags, names, kinds and fields Word saves are read by the same code that reads the bibliography custom XML part of a `.docx`, a tag already in the document is left alone (an import never overwrites an edited source), an untagged source gets a tag the way a new one does, the whole import is one undo step, and a file that isn't `b:Sources` is refused. | @@ -191,6 +196,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. | | 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). | | 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open | +| 2026-10-16 | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. | | 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form | | 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right | | 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. | @@ -284,6 +290,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released | | 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) | | 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. | +| 2026-10-14 | Tables nest 24 deep at most, the depth the .docx reader and writer keep (#1596): pasting a table into its own cell again and again no longer builds a document that crashes when it is saved as JSON, copied, closed or replaced by a new one. A table that would go deeper arrives as its paragraphs, text kept, and Insert › Table in a cell 24 tables deep says why it can't. Word has no limit | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | @@ -535,9 +542,13 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-21 | trivial | Progress log: File › Recover follows the app theme (#3243) | | 2026-10-21 | 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) | | 2026-10-21 | trivial | Progress log: table style band sizes, banded rows as in Word (#3415) | +| 2026-10-21 | trivial | Progress log: ODT table column widths are read, so widths set in LibreOffice or changed in WordCraft survive a save | | 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged | | 2026-10-20 | trivial | Progress log: a click below a floating table goes to the text after it, not its last row (#2038) | | 2026-10-20 | trivial | Progress log: the table handles show while the caret is in the table (#2428) | +| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. | +| 2026-10-20 | trivial | Progress log: a picture cropped to a sliver of its source is drawn at the size the file gives (#1899) | +| 2026-10-20 | trivial | Progress log: Cuban and Peruvian Spanish table numbers use decimal points, matching their CLDR parent locale and existing region settings | | 2026-10-19 | trivial | Progress log: typing no longer stops after clicking Search (#2986) | | 2026-10-19 | trivial | Progress log: Delete on selected table cells leaves one empty paragraph in each (#2802) | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | @@ -548,6 +559,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) | | 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) | +| 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only | | 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) | | 2026-10-18 | trivial | Progress log: Picture Format › Size, sizes typed in any unit and the Size dialog (#2573); readiness estimates unchanged | @@ -600,6 +612,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) | | 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) | | 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. | +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) | | 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) | | 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands | @@ -623,6 +636,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position | | 2026-10-15 | trivial | Progress log: caret inside ligatures (#1974) | | 2026-10-17 | trivial | Progress log: the ribbon's paste choices in the context menu (#2583) | +| 2026-10-16 | trivial | Progress log: live rotation preview while dragging (#2277); readiness estimates unchanged | | 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | @@ -651,6 +665,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) | | 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | +| 2026-10-14 | trivial | Progress log: tables nest 24 deep at most (#1596) | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index db0098c3b..9107139af 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::para::InlineObject; use crate::props::{CharProps, ParaProps}; use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; +use crate::{Table, table::MAX_TABLE_DEPTH}; /// A copied piece of a document: blocks, where the first and last paragraphs may be partial. #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] @@ -136,6 +137,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } } +/// The blocks in `t`'s cells, row by row. +fn cell_blocks(t: &Table) -> impl DoubleEndedIterator { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b) +} + +/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without +/// recursion: a pasted fragment may be deep). +fn table_height(t: &Table) -> usize { + let mut most = 1; + let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect(); + while let Some((b, d)) = stack.pop() { + if let Block::Table(inner) = b { + let d = d.saturating_add(1); + most = most.max(d); + stack.extend(cell_blocks(inner).map(|cb| (cb, d))); + } + } + most +} + +/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by +/// its paragraphs (tables in its cells too, document order), as the DOCX reader does. +fn fit_tables(b: Block, room: usize, out: &mut Vec) { + match b { + Block::Table(t) if room == 0 => { + let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect(); + while let Some(cb) = stack.pop() { + match cb { + Block::Para(p) => out.push(Block::Para(p.clone())), + Block::Table(inner) => stack.extend(cell_blocks(inner).rev()), + } + } + } + Block::Table(mut t) if table_height(&t) > room => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut kept = Vec::new(); + for cb in std::mem::take(&mut cell.blocks) { + fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept); + } + cell.blocks = kept.into_iter().map(Arc::new).collect(); + } + out.push(Block::Table(t)); + } + b => out.push(b), + } +} + /// Nesting limit when following text boxes inside text boxes. const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). @@ -361,8 +409,14 @@ impl Document { Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) } - /// Insert a block before the block at `path` (same container). + /// Insert a block before the block at `path` (same container). A table that would nest + /// deeper than [`MAX_TABLE_DEPTH`] is refused. pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { + if let Block::Table(t) = &block + && path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH + { + return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep"))); + } let i = path.last(); let bl = self.container_mut(story, path)?; if i > bl.len() { @@ -736,7 +790,12 @@ impl Document { /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { - let mut blocks = frag.blocks.clone(); + // Tables that would nest deeper than the limit here become their paragraphs (#1596). + let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth()); + let mut blocks = Vec::with_capacity(frag.blocks.len()); + for b in frag.blocks.iter().cloned() { + fit_tables(b, room, &mut blocks); + } if blocks.is_empty() { return Ok(pos.clone()); } @@ -941,6 +1000,73 @@ mod tests { assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); } + /// The first paragraph of the deepest cell, following the first table at each level. + fn deepest(d: &Document) -> Path { + let (mut path, mut blocks) = (Vec::new(), &d.body); + while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) { + path.extend([i as u32, 0, 0]); + blocks = &t.rows[0].cells[0].blocks; + } + path.push(0); + Path(path) + } + + fn body_height(d: &Document) -> usize { + d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0) + } + + /// How many times `c` is in `blocks`, the deepest cells too. + fn count(blocks: &[Arc], c: char) -> usize { + blocks + .iter() + .map(|b| match &**b { + Block::Para(p) => p.text.matches(c).count(), + Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(), + }) + .sum() + } + + #[test] + fn tables_nest_at_most_max_table_depth() { + let mut d = doc(&["x", ""]); + for _ in 0..MAX_TABLE_DEPTH { + let at = deepest(&d); + d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // One more in the deepest cell is refused and changes nothing; a paragraph is fine. + let (at, before) = (deepest(&d), d.clone()); + assert_eq!(at.depth(), MAX_TABLE_DEPTH); + assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err()); + assert_eq!(d, before); + d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap(); + // One level up, a table fits but a table holding a table doesn't. + let up = Path(at.0[..at.0.len() - 3].to_vec()); + let mut two = Table::new(1, 1, 100.0); + two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0)))); + assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err()); + d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + } + + #[test] + fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() { + // #1596: copy everything and paste it into the deepest cell, again and again: each round + // doubled the nesting, until saving, copying or closing the document overflowed the stack. + let mut d = doc(&["x", ""]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap(); + for _ in 0..8 { + let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0)); + let at = deepest(&d); + let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len); + d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // Nothing is lost: every copy's text is there, below the limit as paragraphs. + assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256)); + } + fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 { let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]); d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap(); diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index 5511ac720..569ab0a4b 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -8,7 +8,7 @@ pub fn decimal_separator(lang: Option<&str>) -> char { let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-"); // Regions that write a decimal point where the language usually writes a comma. const POINT_REGIONS: &[&str] = - &["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"]; + &["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"]; if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) { return '.'; } @@ -151,6 +151,17 @@ mod tests { assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign"); } + #[test] + fn cuban_and_peruvian_numbers_use_decimal_points() { + for lang in [Some("es-CU"), Some("es-PE")] { + assert_eq!(parse_number("1.234", lang), Some(1.234)); + assert_eq!(parse_number("1,234.50", lang), Some(1234.5)); + assert_eq!(format_number(5.75, lang), "5.75"); + } + assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5)); + assert_eq!(format_number(5.75, Some("es-ES")), "5,75"); + } + #[test] fn english_numbers() { let en = Some("en-US"); diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index e004b9086..d815bad7a 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -77,12 +77,17 @@ pub enum WrapText { impl WrapText { /// From the OOXML value (`bothSides`, `left`, `right`, `largest`). pub fn from_ooxml(v: &str) -> Option { - match v { - "bothSides" => Some(WrapText::BothSides), - "left" => Some(WrapText::Left), - "right" => Some(WrapText::Right), - "largest" => Some(WrapText::Largest), - _ => None, + let v = v.trim(); + if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") { + Some(WrapText::BothSides) + } else if v.eq_ignore_ascii_case("left") { + Some(WrapText::Left) + } else if v.eq_ignore_ascii_case("right") { + Some(WrapText::Right) + } else if v.eq_ignore_ascii_case("largest") { + Some(WrapText::Largest) + } else { + None } } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9a260d4c4..3bfbdb74f 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -65,6 +65,10 @@ pub struct Table { /// Hard limits that keep hostile input from exhausting memory. pub const MAX_ROWS: usize = 32_767; pub const MAX_COLS: usize = 63; +/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting +/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack +/// when it is saved, copied or dropped (#1596). +pub const MAX_TABLE_DEPTH: usize = 24; impl Table { /// A `rows × cols` table spanning `width` points, "Table Grid" style. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index f5d3d3d78..d7991f80e 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1048,14 +1048,19 @@ impl Reader<'_> { float.effect = float.effect.map(|e| e.max(0.0)); return Some(InlineObject::Graphic { w, h, alt, float, graphic }); } + // A textbox may contain a picture; its descendant blip is not the outer payload. + let direct_shape = gd.child("wps:wsp").filter(|shape| { + // Picture fills still use the image fallback; the shape model has no image fill. + shape.child("wps:spPr").and_then(|props| props.child("a:blipFill")).is_none() + }); let mut obj = if let Some(c) = gd.child("wpc:wpc") { self.read_canvas(sc, c, rels, w, h, float) } else if let Some(g) = gd.child("wpg:wgp") { self.read_group(sc, g, rels, w, h, float)? - } else if gd.find("a:blip").is_some() { + } else if direct_shape.is_none() && gd.find("a:blip").is_some() { self.read_pic(gd, rels, w, h, alt.clone(), float)? } else { - let wsp = gd.find("wps:wsp")?; + let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?; let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); // A freeform: whether it's ink WordCraft wrote. if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj @@ -1635,7 +1640,12 @@ impl Reader<'_> { let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#')); let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id))); let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name))); - let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0")); + let on = |name: &str| { + attr(name).is_none_or(|v| { + let t = v.trim(); + !t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0" + }) + }; let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let fill = vml_fill(shape, shapetype); @@ -1713,14 +1723,17 @@ impl Reader<'_> { } part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); // Resize shape to fit text (DrawingML's `a:spAutoFit`). - let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text"); + let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text")); part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); // Where the text sits between the box's top and bottom (DrawingML's `anchor`). - if let Some(a) = vml_style(style, "v-text-anchor") { - part.body.anchor = match a { - a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center, - a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom, - _ => wordcraft_doc::props::VAlign::Top, + if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) { + let al = a.trim().to_ascii_lowercase(); + part.body.anchor = if al.starts_with("middle") || al == "center" { + wordcraft_doc::props::VAlign::Center + } else if al.starts_with("bottom") { + wordcraft_doc::props::VAlign::Bottom + } else { + wordcraft_doc::props::VAlign::Top }; } } @@ -2137,37 +2150,43 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float { let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0); let max = crate::units::MAX_LEN_PT; let behind = get("z-index").and_then(int).is_some_and(|z| z < 0); - let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") { - "square" => Wrap::Square, - "tight" => Wrap::Tight, - "through" => Wrap::Through, - "topAndBottom" => Wrap::TopAndBottom, + let wrap_el = shape.child("w10:wrap"); + let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none"); + let wrap = match wrap_type { + t if t.eq_ignore_ascii_case("square") => Wrap::Square, + t if t.eq_ignore_ascii_case("tight") => Wrap::Tight, + t if t.eq_ignore_ascii_case("through") => Wrap::Through, + t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom, _ if behind => Wrap::BehindText, _ => Wrap::InFrontOfText, }; - let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") { - "page" => Anchor::Page, - "margin" => Anchor::Margin, - "char" => Anchor::Character, - "left-margin-area" => Anchor::LeftMargin, - "right-margin-area" => Anchor::RightMargin, - "inner-margin-area" => Anchor::InsideMargin, - "outer-margin-area" => Anchor::OutsideMargin, + let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default(); + let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text"); + let h_rel = match h_rel_str { + s if s.eq_ignore_ascii_case("page") => Anchor::Page, + s if s.eq_ignore_ascii_case("margin") => Anchor::Margin, + s if s.eq_ignore_ascii_case("char") => Anchor::Character, + s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin, + s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin, + s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin, + s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin, _ => Anchor::Column, }; - let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") { - "page" => Anchor::Page, - "margin" => Anchor::Margin, - "line" => Anchor::Line, - "top-margin-area" => Anchor::TopMargin, - "bottom-margin-area" => Anchor::BottomMargin, - "inner-margin-area" => Anchor::InsideMargin, - "outer-margin-area" => Anchor::OutsideMargin, + let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text"); + let v_rel = match v_rel_str { + s if s.eq_ignore_ascii_case("page") => Anchor::Page, + s if s.eq_ignore_ascii_case("margin") => Anchor::Margin, + s if s.eq_ignore_ascii_case("line") => Anchor::Line, + s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin, + s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin, + s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin, + s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin, _ => Anchor::Paragraph, }; let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0); let mut f = Float { wrap, + wrap_text, h_rel, v_rel, x: (off("left") + off("margin-left")).clamp(-max, max), diff --git a/crates/docx/tests/drawing_payload.rs b/crates/docx/tests/drawing_payload.rs new file mode 100644 index 000000000..7b3d251f4 --- /dev/null +++ b/crates/docx/tests/drawing_payload.rs @@ -0,0 +1,159 @@ +//! Original nested-picture controls. No native output or external assets are embedded. +use std::{io::Write, sync::Arc}; +use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap}; +use wordcraft_doc::props::Rgb; +use wordcraft_doc::{Document, InlineObject, Paragraph, PartKind, Pos, StoryRef, TextBody, para_block}; + +// Original solid RGB(0,72,192), generated in code for these controls. +const BLUE_PNG: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 64, 0, 0, 0, 16, 8, 2, 0, 0, 0, 41, 133, 238, 126, 0, 0, 0, 54, 73, 68, + 65, 84, 120, 156, 237, 207, 65, 9, 0, 48, 12, 192, 192, 202, 155, 236, 73, 171, 136, 61, 142, 65, 32, 2, 46, 51, 231, 254, 157, 23, 52, 160, 5, + 13, 104, 65, 3, 90, 208, 128, 22, 52, 160, 5, 13, 104, 65, 3, 90, 240, 214, 2, 129, 33, 32, 61, 59, 114, 68, 120, 0, 0, 0, 0, 73, 69, 78, 68, + 174, 66, 96, 130, +]; +const INLINE_XML: &str = r#" +2533 Plain | red parent 180 pt wide | saved height 80 pt9144001270000Parent"#; + +fn picture() -> InlineObject { + InlineObject::Image { + media: "original-blue.png".into(), + w: 40.0, + h: 10.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + } +} +fn model_box() -> Document { + let mut doc = Document::new(); + doc.media.insert("original-blue.png".into(), Arc::new(BLUE_PNG.to_vec())); + let id = doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Parent", Default::default()))]); + doc.parts.get_mut(&id).unwrap().body = TextBody { auto_fit: true, inset: Some([0; 4]), ..Default::default() }; + doc.insert_object(&Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path(vec![0]), off: 0 }, picture(), &Default::default()).unwrap(); + let parent = InlineObject::Shape { + kind: ShapeKind::TextBox, + w: 180.0, + h: 80.0, + fill: Some(wordcraft_doc::fill::Fill::solid(Rgb(240, 248, 240))), + stroke: Some(Rgb(192, 0, 0)), + stroke_width: 1.0, + float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 100.0, ..Default::default() }, + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + doc.insert_object(&Pos::body(0, 0), parent, &Default::default()).unwrap(); + doc +} +fn check_box(doc: &Document) { + let objects = &doc.body[0].as_para().unwrap().objects; + assert_eq!(objects.len(), 1); + let InlineObject::Shape { kind, w, h, float, story: Some(id), stroke, stroke_width, .. } = &objects[0] else { + panic!("outer textbox lost: {:?}", objects[0]); + }; + assert_eq!((*kind, *w, *h, *stroke, *stroke_width), (ShapeKind::TextBox, 180.0, 80.0, Some(Rgb(192, 0, 0)), 1.0)); + assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::InFrontOfText, Anchor::Page, Anchor::Page, 72.0, 100.0)); + let part = &doc.parts[id]; + assert_eq!(part.kind, PartKind::TextBox); + assert!(part.body.auto_fit); + assert_eq!(part.body.inset, Some([0; 4])); + assert_eq!(doc.parts.len(), 1); + let para = part.blocks[0].as_para().unwrap(); + assert_eq!(para.text, "\u{fffc}Parent"); + assert_eq!(para.objects.len(), 1); + let InlineObject::Image { w, h, float, media, .. } = ¶.objects[0] else { + panic!("inline picture lost"); + }; + assert_eq!((*w, *h, float.wrap), (40.0, 10.0, Wrap::Inline)); + assert_eq!(doc.media[media].as_slice(), BLUE_PNG); +} +fn package(xml: &str) -> Vec { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let rels = br#""#; + let docrels = br#""#; + let types = br#""#; + for (name, bytes) in [ + ("_rels/.rels", rels.as_slice()), + ("[Content_Types].xml", types.as_slice()), + ("word/document.xml", xml.as_bytes()), + ("word/_rels/document.xml.rels", docrels.as_slice()), + ("word/media/isolation-blue.png", BLUE_PNG), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.finish().unwrap().into_inner() +} +#[test] +fn model_nested_picture_roundtrip() { + let doc = model_box(); + check_box(&doc); + let bytes = wordcraft_docx::write(&doc).unwrap(); + check_box(&wordcraft_docx::read(&bytes).unwrap()); +} +#[test] +fn original_inline_xml_keeps_box() { + let doc = wordcraft_docx::read(&package(INLINE_XML)).unwrap(); + check_box(&doc); + check_box(&wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap()); +} +#[test] +fn picture_group_canvas_keep_kind() { + let group = |canvas| InlineObject::Group { + w: 100.0, + h: 50.0, + float: Default::default(), + ch_w: 100.0, + ch_h: 50.0, + children: vec![GroupChild { x: 3.0, y: 4.0, obj: picture() }], + canvas, + }; + for original in [picture(), group(None), group(Some(CanvasStyle::default()))] { + let mut doc = Document::new(); + doc.media.insert("original-blue.png".into(), Arc::new(BLUE_PNG.to_vec())); + doc.insert_object(&Pos::body(0, 0), original.clone(), &Default::default()).unwrap(); + let got = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + let object = &got.body[0].as_para().unwrap().objects[0]; + match (&original, object) { + (InlineObject::Image { .. }, InlineObject::Image { w, h, media, .. }) => { + assert_eq!((*w, *h), (40.0, 10.0)); + assert_eq!(got.media[media].as_slice(), BLUE_PNG); + } + (InlineObject::Group { canvas: expected, .. }, InlineObject::Group { w, h, children, canvas, .. }) => { + assert_eq!((*w, *h), (100.0, 50.0)); + assert_eq!(canvas.is_some(), expected.is_some()); + assert_eq!(children.len(), 1); + let child = &children[0]; + assert_eq!((child.x, child.y), (3.0, 4.0)); + let InlineObject::Image { w, h, media, .. } = &child.obj else { + panic!("picture member changed kind"); + }; + assert_eq!((*w, *h), (40.0, 10.0)); + assert_eq!(got.media[media].as_slice(), BLUE_PNG); + } + _ => panic!("payload changed kind: {object:?}"), + } + assert!(got.parts.is_empty()); + } +} + +#[test] +fn own_picture_fill_keeps_image() { + // Keep the existing WPS picture-fill fallback, distinct from a nested picture. + let start = INLINE_XML.find("").unwrap(); + let end = INLINE_XML.find("").unwrap(); + let payload = r#""#; + let xml = format!("{}{}{}", &INLINE_XML[..start], payload, &INLINE_XML[end..]); + let doc = wordcraft_docx::read(&package(&xml)).unwrap(); + assert!(doc.parts.is_empty()); + let objects = &doc.body[0].as_para().unwrap().objects; + assert_eq!(objects.len(), 1); + let InlineObject::Image { w, h, media, float, .. } = &objects[0] else { + panic!("own picture fill lost: {:?}", objects[0]); + }; + assert_eq!((*w, *h), (180.0, 80.0)); + assert_eq!((float.wrap, float.x, float.y), (Wrap::InFrontOfText, 72.0, 100.0)); + assert_eq!(doc.media[media].as_slice(), BLUE_PNG); +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2f4143a90..537e1dacc 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -3698,3 +3698,53 @@ fn our_banded_table_styles_write_their_band_sizes() { let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1))); } + +#[test] +fn vml_shape_wrap_side_and_anchor_case_insensitivity() { + let body = r##" + + + + + + + + "##; + let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = doc.body.iter().find_map(|b| b.as_para()).unwrap(); + let shape = match p.objects.first() { + Some(InlineObject::Shape { fill, float, .. }) => (fill, float), + o => panic!("expected shape, got {o:?}"), + }; + assert_eq!(*shape.0, None); + assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square); + assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left); + assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page); + assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin); +} + +#[test] +fn vml_textbox_style_anchor_and_fit() { + let body = r#" + + + + + + Centered text + + + + + + "#; + let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = doc.body.first().unwrap().as_para().unwrap(); + let story_id = match p.objects.first() { + Some(InlineObject::Shape { story: Some(id), .. }) => *id, + o => panic!("expected shape with story, got {o:?}"), + }; + let tb = doc.parts.get(&story_id).unwrap(); + assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center); + assert!(tb.body.auto_fit); +} diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 1e3ac2590..dda7f614a 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -196,3 +196,41 @@ fn hidden_drawings_stay_hidden() { assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}"); assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want); } + +#[test] +fn picture_in_box_keeps_its_frame() { + use wordcraft_doc::{Paragraph, Pos, StoryRef}; + + let mut d = Document::from_text("Anchor"); + let body = TextBody { anchor: VAlign::Center, inset: Some([0; 4]), ..Default::default() }; + let outer = text_box(&mut d, "Label", body); + let id = outer.text_box().unwrap(); + let mut bytes = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let media = d.add_media(bytes.into_inner(), "png"); + let image = InlineObject::Image { + media: media.clone(), + w: 90.0, + h: 36.0, + alt: "Synthetic marker".into(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + let mut p = Paragraph::new(); + p.insert_object(0, image, &Default::default()).unwrap(); + d.parts.get_mut(&id).unwrap().blocks.push(para_block(p)); + d.insert_object(&Pos::body(0, 0), outer, &Default::default()).unwrap(); + + let read = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let ids = boxes(&read); + assert_eq!(ids.len(), 1, "a nested picture must not replace its text box"); + let id = ids[0]; + assert_eq!(read.parts[&id].body, body); + assert!(read.plain_text(StoryRef::Part(id)).starts_with("Label")); + let p = read.para(StoryRef::Part(id), &wordcraft_doc::Path::top(1)).unwrap(); + assert!(matches!(&p.objects[0], InlineObject::Image { w, h, alt, .. } + if *w == 90.0 && *h == 36.0 && alt == "Synthetic marker")); + let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0)); +} diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index c4e38a2ba..12a65494f 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -378,8 +378,33 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult { select_unit(s, Unit::Sentence) } -fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { - select_unit(s, Unit::Paragraph) +/// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word +/// takes its end mark too, which shades it), so its borders then move on their own +/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected. +fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult { + let r = select_unit(s, Unit::Paragraph); + if v.get("cell").and_then(Value::as_bool) != Some(true) { + return r; + } + let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone()); + let n = a.path.0.len(); + if a.story == b.story + && a.path.0.len() == b.path.0.len() + && let Some(tp) = a.path.0.get(..n.saturating_sub(3)) + && !tp.is_empty() + && a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1)) + && let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2))) + { + let tp = wordcraft_doc::Path(tp.to_vec()); + let (row, cell) = (row as usize, cell as usize); + let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1)); + let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l)); + let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell)); + if let (true, Some(cols)) = (only, grid) { + return super::table::select_range(s, a.story, &tp, (row, row), cols); + } + } + r } /// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index aa61944ed..3863f1d38 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { .when(has_sizable), CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop) .params( - r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.45; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#, + r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#, ) .when(has_picture), // Kept for scripts written before every object had alt text: `object.altText` on a picture. @@ -970,13 +970,17 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult { /// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays /// visible keeps its place on the page: a crop handle drop is one command. fn crop(s: &mut Session, v: &Value) -> CmdResult { - let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45)); + let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95)); + // The two sides of an axis keep at least 5% of the source between them, as the renderer + // draws it: the second side stops where the first one already left less than that. + c[2] = c[2].min(0.95 - c[0]); + c[3] = c[3].min(0.95 - c[1]); let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0)); let (nw, nh) = (size("width"), size("height")); with_obj(s, |o| { if let InlineObject::Image { crop, w, h, float, .. } = o { // The source's full size at the new display size (the visible part is 1-lo-hi of it). - let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.1); + let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05); // Flipped, the source's right (bottom) edge is the one on the page's left (top). let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 }); if let Some(nw) = nw { @@ -1025,7 +1029,7 @@ pub fn original_size(s: &Session, media: &str) -> Option { pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> { let InlineObject::Image { media, crop, .. } = o else { return None }; let orig = original_size(s, media)?; - let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.1); + let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05); Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3]))) } @@ -2263,14 +2267,45 @@ mod tests { assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1])); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(image(&s), (200.0, 100.0, [0.0; 4])); - // Clamps: each side at most 0.45; hostile sizes are ignored or clamped, never applied raw. + // Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw. s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap(); let (w, h, crop) = image(&s); - assert_eq!(crop, [0.45, 0.0, 0.0, 0.0]); + assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]); assert_eq!(w, 200.0); assert!(h >= 1.0, "{h}"); } + /// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to + /// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%. + #[test] + fn a_picture_can_be_cropped_to_any_part_of_itself() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data})).unwrap(); + s.run("picture.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap(); + let crop = |s: &Session| match selected(s) { + Some((_, InlineObject::Image { crop, .. })) => crop, + other => panic!("expected image, got {other:?}"), + }; + let left = |s: &Session| { + let (_, o) = selected(s).unwrap(); + super::cropped_original_size(s, &o).unwrap().0 + }; + // 90% off the left keeps the rightmost tenth: the side is not held at 45%. + s.run("picture.crop", &json!({"left": 0.9})).unwrap(); + assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]); + assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s)); + // With the left at 0.9 the right can take only 5% more, and what the crop leaves of the + // original follows the crop down to that 5%. + s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap(); + let c = crop(&s); + assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}"); + assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s)); + // A single side past the cap stops at 0.95. + s.run("picture.crop", &json!({"right": 4.0})).unwrap(); + assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]); + } + /// Cropping a floating picture's left or top with its new size keeps the visible part where /// it is on the page: the frame moves by the trimmed amount, in the same undo step. #[test] diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index e0e519801..4550b4ece 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -724,13 +724,21 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result { } "ABS" => Ok(one(name, args, here)?.abs()), "INT" => Ok(one(name, args, here)?.floor()), - "SIGN" => Ok(one(name, args, here)?.signum()), + "SIGN" => { + let x = one(name, args, here)?; + Ok(if x == 0.0 { 0.0 } else { x.signum() }) + } "ROUND" => match args { [a, b] => { let x = scalar(eval(a, here)?)?; let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc(); let scale = 10f64.powi(digits as i32); - finite((x * scale).round() / scale) + let scaled = x * scale; + if !scaled.is_finite() { + // Too large to have a fractional part at this precision. + return finite(x); + } + finite(scaled.round() / scale) } _ => Err(CmdError::Failed("ROUND takes two arguments".into())), }, @@ -769,6 +777,16 @@ mod tests { evaluate(t, r, c, f) } + #[test] + fn sign_of_zero_is_zero() { + let t = table(&[&["0", "5", "-5"]]); + assert_eq!(ev(&t, 0, 0, "=SIGN(0)"), Ok(0.0)); + assert_eq!(ev(&t, 0, 0, "=SIGN(-0)"), Ok(0.0)); + assert_eq!(ev(&t, 0, 1, "=SIGN(A1)"), Ok(0.0), "a cell holding 0"); + assert_eq!(ev(&t, 0, 0, "=SIGN(5)"), Ok(1.0)); + assert_eq!(ev(&t, 0, 0, "=SIGN(-5)"), Ok(-1.0)); + } + #[test] fn cell_references_count_from_one_and_from_a() { assert_eq!(cell_ref("A1"), Some((0, 0))); @@ -807,6 +825,14 @@ mod tests { assert!(err.contains("MEDIAN"), "{err}"); } + #[test] + fn round_large_values_return_input() { + let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); + let big = format!("1{}", "0".repeat(300)); + assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},15)")), Ok(1e300)); + assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},2)")), Ok(1e300)); + } + #[test] fn ranges_and_references_and_operators() { let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 70f04f0c4..af51c22fd 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -1016,4 +1016,130 @@ mod tests { assert_eq!(first_table(&s).grid.len(), 3, "no new grid line"); assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows"); } + + /// Selects the cell at (row, cell) one of the ways Word does: Select Cell, the left-edge + /// arrow, or a triple-click on its text. + fn select_one_cell(s: &mut Session, how: &str, r: usize, c: usize) { + let b = cell_rect(s, r, c); + let y = b.y + b.h / 2.0; + match how { + "arrow" => { + s.run("table.selectAt", &json!({"page": 0, "x": b.x + 4.5, "y": y})).unwrap(); + } + "triple" => { + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("select.paragraph", &json!({"cell": true})).unwrap(); + } + _ => { + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("table.selectCell", &json!({})).unwrap(); + } + } + } + + /// Each way of selecting one cell makes its right and its left border move in its row + /// only; the neighbour on that side gives up or gains the room and the row keeps its width. + #[test] + fn a_single_selected_cells_borders_move_only_in_its_row() { + for how in ["cell", "arrow", "triple"] { + for left in [false, true] { + let mut s = table_session(3, 3); + let (a, b, c) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + let y = b.y + b.h / 2.0; + select_one_cell(&mut s, how, 1, 1); + let start = first_table(&s); + let x = if left { b.x } else { b.right() }; + s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": y, "toX": x + 30.0, "toY": y})).unwrap(); + let (a1, b1, c1) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + if left { + assert!((b1.w - (b.w - 30.0)).abs() < 1.0 && (a1.w - (a.w + 30.0)).abs() < 1.0, "{how} left: {a1:?} {b1:?}"); + assert!((c1.w - c.w).abs() < 0.5, "{how} left"); + } else { + assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{how} right: {b1:?} {c1:?}"); + assert!((a1.w - a.w).abs() < 0.5, "{how} right"); + } + assert!((c1.right() - c.right()).abs() < 0.5, "{how}: the row keeps its width"); + for r in [0, 2] { + assert!( + (cell_rect(&mut s, r, 1).x - b.x).abs() < 0.5 && (cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5, + "{how}: row {r} unchanged" + ); + } + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start, "{how}: one undo step"); + } + } + } + + /// A caret, a part of a cell's text, or another cell's border with a cell selected: the + /// whole column moves. + #[test] + fn without_that_cell_selected_the_whole_column_moves() { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "caret only"); + s.run("edit.undo", &json!({})).unwrap(); + select_one_cell(&mut s, "arrow", 2, 2); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected"); + s.run("edit.undo", &json!({})).unwrap(); + // All of a cell's text, picked as a range (no cell mark): not a cell selection. + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("text.insert", &json!({"text": "Wide"})).unwrap(); + s.run("select.text", &json!({"text": "Wide"})).unwrap(); + let (anchor, focus) = (serde_json::to_value(&s.sel.anchor).unwrap(), serde_json::to_value(&s.sel.focus).unwrap()); + s.run("select.range", &json!({"anchor": anchor, "focus": focus})).unwrap(); + assert!(s.cell_range().is_none()); + let b = cell_rect(&mut s, 1, 1); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "all of a cell's text selected"); + } + + /// The jagged row a single cell's drag leaves is saved to .docx and read back the same. + #[test] + fn a_jagged_row_survives_a_docx_round_trip() { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + select_one_cell(&mut s, "arrow", 1, 1); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + let t = first_table(&s); + let bytes = crate::io::save_bytes("jagged.docx", &s.doc).unwrap(); + let doc = crate::io::open_bytes("jagged.docx", &bytes).unwrap(); + let back = doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap(); + let spans = |t: &Table| t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect::>()).collect::>(); + assert_eq!(spans(&back), spans(&t)); + let mut s2 = Session::new(doc); + for c in 0..spans(&t)[1].len() { + assert!((cell_rect(&mut s2, 1, c).w - cell_rect(&mut s, 1, c).w).abs() < 1.0, "cell {c}"); + } + } + + /// In a cell of two paragraphs, a triple-click on the last one selects the cell (its border + /// then moves in its row only); on the first one it selects text, and the whole column moves. + #[test] + fn triple_click_selects_a_cell_only_on_its_last_paragraph() { + for last in [true, false] { + let mut s = table_session(3, 3); + let b = cell_rect(&mut s, 1, 1); + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap(); + s.run("text.insert", &json!({"text": "One"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Two"})).unwrap(); + s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap(); + s.run("select.paragraph", &json!({})).unwrap(); + assert!(s.cell_range().is_none(), "the ribbon's Select Paragraph never selects the cell"); + s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap(); + s.run("select.paragraph", &json!({"cell": true})).unwrap(); + assert_eq!(s.cell_range().is_some(), last); + let b = cell_rect(&mut s, 1, 1); + let y = b.y + b.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + let moved = (cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0; + assert_eq!(moved, !last, "last paragraph {last}: the column moves only without a cell selection"); + } + } } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index f19b8d444..77b54b2ba 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -632,14 +632,19 @@ pub fn specs() -> Vec { .params(r#"{"header"?: pt, "footer"?: pt}"#), CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| { let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) }; + let story = s.sel.focus.story; + let current = { + let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; + let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id)); + table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4]) + }; let m = [ - p::f32(v, "top").unwrap_or(0.0), - p::f32(v, "left").unwrap_or(5.4), - p::f32(v, "bottom").unwrap_or(0.0), - p::f32(v, "right").unwrap_or(5.4), + p::f32(v, "top").unwrap_or(current[0]), + p::f32(v, "left").unwrap_or(current[1]), + p::f32(v, "bottom").unwrap_or(current[2]), + p::f32(v, "right").unwrap_or(current[3]), ] .map(|x| x.clamp(0.0, 144.0)); - let story = s.sel.focus.story; s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m); for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) { s.doc.para_mut(story, &path)?.touch(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 45090ac8f..31edd2dab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1781,6 +1781,27 @@ fn select_extend_from_a_table_paragraph_selects_the_document() { assert_eq!(b, s.doc.end_of(StoryRef::Body)); } +/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table +/// in the deepest cell says so; a table pasted there brings its text as paragraphs. +#[test] +fn tables_nest_at_most_24_deep() { + use wordcraft_doc::table::MAX_TABLE_DEPTH; + let mut s = s(); + for _ in 0..MAX_TABLE_DEPTH { + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + } + assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH); + let before = s.doc.clone(); + let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err(); + assert!(e.to_string().contains("24 deep"), "{e}"); + assert_eq!(s.doc, before); + run(&mut s, "edit.paste", json!({"text": "b", "html": "
b
"})); + let cell = s.sel.focus.path.clone(); + let blocks: Vec<_> = (0..).map_while(|i| s.doc.block(StoryRef::Body, &cell.with_last(i))).collect(); + assert!(blocks.iter().all(|b| b.as_table().is_none()), "no table below the limit"); + assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there"); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] @@ -8777,3 +8798,36 @@ fn rtf_list_round_trip_keeps_text() { assert_eq!(d.plain_text(StoryRef::Body), "first\nsecond", "{cmd}"); } } + +/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. +#[test] +fn table_cell_margins_preserves_unpassed_sides() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + + // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"top": 20.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); + + // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"left": 10.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); + + // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); + + // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). + run(&mut s, "table.cellMargins", json!({"right": 15.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); + + // 5. Updating multiple sides at once retains unpassed sides. + run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index c9133e10d..8ec9f31cb 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -15,6 +15,7 @@ use std::sync::Arc; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ @@ -1295,6 +1296,26 @@ impl<'r> Builder<'r> { } let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63)); let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background); + let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }); + if let Some(st) = attr(attrs, "style") { + for decl in st.split(';') { + if let Some((k, v)) = decl.split_once(':') + && k.trim().eq_ignore_ascii_case("vertical-align") + { + match v.trim().to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} + } + } + } + } self.cells.push(Cell { blocks: Vec::new(), colspan: num("colspan").unwrap_or(1), @@ -1303,6 +1324,7 @@ impl<'r> Builder<'r> { header: name == "th", shading, borders: None, + valign, }); fmt.background = None; self.containers.push(Vec::new()); @@ -1771,7 +1793,12 @@ fn table_html(t: &FTable, out: &mut String, depth: usize) { if c.rowspan > 1 { attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan)); } - let mut css = vec!["padding:4px 6px".to_string(), "vertical-align:top".to_string()]; + let v_css = match c.valign { + Some(VAlign::Center) => "vertical-align:middle", + Some(VAlign::Bottom) => "vertical-align:bottom", + _ => "vertical-align:top", + }; + let mut css = vec!["padding:4px 6px".to_string(), v_css.to_string()]; if !t.borderless { css.insert(0, "border:1px solid #999".to_string()); } @@ -2380,4 +2407,22 @@ mod tests { i => panic!("expected text inline, got {i:?}"), } } + + #[test] + fn table_cell_vertical_alignment() { + let f = parse("
topmidbot
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Top)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() }; + assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let html = export(&doc); + assert!(html.contains("vertical-align:top")); + assert!(html.contains("vertical-align:middle")); + assert!(html.contains("vertical-align:bottom")); + } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index e32b5e122..af8c8ae43 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for}; use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise}; use wordcraft_doc::props::{ - Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VMerge, VertAlign, + Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind}; @@ -273,11 +273,12 @@ pub struct Cell { pub shading: Option, /// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style. pub borders: Option, + pub valign: Option, } impl Default for Cell { fn default() -> Self { - Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None } + Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None } } } @@ -766,7 +767,15 @@ impl<'a> Builder<'a> { VMerge::None }; row.cells.push(wordcraft_doc::Cell { - props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, borders: c.borders, ..Default::default() }, + props: CellProps { + width: Some(w), + span: span as u32, + vmerge, + shading: c.shading, + borders: c.borders, + valign: c.valign, + ..Default::default() + }, blocks, controls: Default::default(), }); @@ -1136,6 +1145,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable header, shading: c.props.shading, borders: c.props.borders, + valign: c.props.valign, }); } rows.push(cells); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index ca7dee7e0..77038aad7 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -22,6 +22,7 @@ use std::io::{Cursor, Read, Write}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; +use wordcraft_doc::props::VAlign; use wordcraft_doc::section::{Columns, SectionStart}; use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; @@ -105,8 +106,8 @@ struct Writer { last_list: Option, /// The page the block being written starts. master: Option, - /// Cell styles by shading. - cell_styles: Vec>, + /// Cell styles by (shading, vertical-alignment). + cell_styles: Vec<(Option, Option)>, pictures: Vec<(String, std::sync::Arc>, String)>, /// `text:changed-region` elements written so far. regions: Vec, @@ -583,11 +584,12 @@ impl Writer { } } - fn cell_style(&mut self, shading: Option) -> String { - let k = match self.cell_styles.iter().position(|s| *s == shading) { + fn cell_style(&mut self, shading: Option, valign: Option) -> String { + let key = (shading, valign); + let k = match self.cell_styles.iter().position(|s| *s == key) { Some(k) => k, None => { - self.cell_styles.push(shading); + self.cell_styles.push(key); self.cell_styles.len() - 1 } }; @@ -625,7 +627,7 @@ impl Writer { } continue; } - let st = self.cell_style(c.shading); + let st = self.cell_style(c.shading, c.valign); let mut attrs = format!(" table:style-name=\"{st}\" office:value-type=\"string\""); if span > 1 { attrs.push_str(&format!(" table:number-columns-spanned=\"{span}\"")); @@ -708,10 +710,16 @@ impl Writer { )); } } - for (i, sh) in self.cell_styles.iter().enumerate() { + for (i, (sh, va)) in self.cell_styles.iter().enumerate() { let bg = sh.map(|c| format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())).unwrap_or_default(); + let va_str = match va { + Some(VAlign::Top) => " style:vertical-align=\"top\"", + Some(VAlign::Center) => " style:vertical-align=\"middle\"", + Some(VAlign::Bottom) => " style:vertical-align=\"bottom\"", + None => "", + }; s.push_str(&format!( - "", + "", i + 1 )); } @@ -1064,6 +1072,10 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// `style:vertical-align` of a table-cell style's `style:table-cell-properties`. + cell_valign: Option, + /// `style:column-width` of a table-column style's `style:table-column-properties`, in points. + column_width: Option, /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. master_page: Option, @@ -1321,11 +1333,26 @@ impl Styles { s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); } } - "table-cell-properties" => { + "table-column-properties" => { if let Some((_, s)) = &mut cur - && let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) + && let Some(w) = get(&a, "style:column-width").and_then(length) { - s.cell_background = Some(c); + s.column_width = Some(w); + } + } + "table-cell-properties" => { + if let Some((_, s)) = &mut cur { + if let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) { + s.cell_background = Some(c); + } + if let Some(va) = get(&a, "style:vertical-align") { + s.cell_valign = match va.trim() { + "top" => Some(VAlign::Top), + "middle" | "center" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + }; + } } } "paragraph-properties" => { @@ -1442,11 +1469,21 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.columns.clone()) } + /// Width in points of a table-column style (whole chain). + fn column_width(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.column_width) + } + /// Background colour of a table-cell style (whole chain). fn cell_background(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_background) } + /// Vertical alignment of a table-cell style (whole chain). + fn cell_valign(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.cell_valign) + } + /// Paragraph kind, alignment, page break, rule, keep-with-next and base formatting. The text formatting of /// every style in the chain applies (automatic and common, ODF 1.3 §16.2 inheritance), except /// the style that gives the paragraph its kind and that style's ancestors: their formatting @@ -1498,6 +1535,16 @@ struct TableB { row: Vec, /// Covered cells still owed to the previous cell's column span. hcover: usize, + /// Widths in points of the `table:table-column`s so far, 0 where the style gives none. + widths: Vec, +} + +impl TableB { + fn finish(self) -> FTable { + // A column without a width leaves the whole grid to the equal split. + let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() }; + FTable { rows: self.rows, widths, borderless: false } + } } /// A `text:changed-region` read from `text:tracked-changes`. @@ -1651,7 +1698,7 @@ impl<'a> Body<'a> { let mut blocks = self.containers.pop().unwrap_or_default(); for t in self.tables.drain(..) { if !t.rows.is_empty() { - blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); + blocks.push(FBlock::Table(t.finish())); } } blocks @@ -2037,7 +2084,15 @@ impl Body<'_> { self.skip = 1; return; } - self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0 }); + self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new() }); + } + "table-column" => { + let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0); + let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).max(1); + if let Some(t) = self.tables.last_mut() { + let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len()); + t.widths.extend(std::iter::repeat_n(w, n.min(room))); + } } "table-row" => { if let Some(t) = self.tables.last_mut() { @@ -2050,10 +2105,11 @@ impl Body<'_> { let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 63); let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1000); let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s)); + let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s)); if let Some(t) = self.tables.last_mut() { t.hcover = span as usize - 1; } - self.cells.push(Cell { colspan: span, rowspan: rows, shading, ..Default::default() }); + self.cells.push(Cell { colspan: span, rowspan: rows, shading, valign, ..Default::default() }); self.containers.push(Vec::new()); self.lists_saved.push((std::mem::take(&mut self.lists), std::mem::take(&mut self.item_used))); } @@ -2346,7 +2402,7 @@ impl Body<'_> { if let Some(t) = self.tables.pop() && !t.rows.is_empty() { - self.container().push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); + self.container().push(FBlock::Table(t.finish())); } } "desc" | "title" => self.in_desc = false, @@ -2949,6 +3005,54 @@ mod tests { } } + #[test] + fn table_column_styles_are_the_grid_widths() { + // A column's `style:column-width` (and `table:number-columns-repeated`) sets its width. + let content = format!( + r##"ABC"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert_eq!(t.widths, vec![72.0, 144.0, 144.0]); + } + + #[test] + fn table_column_widths_survive_a_round_trip() { + // A width changed in the document is written and read back; the file's own widths are + // what the first import shows. + let content = format!( + r##"AB"## + ); + let grid = |doc: &Document| match doc + .body + .iter() + .find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.grid.clone()) } else { None }) + { + Some(g) => g, + None => panic!("no table"), + }; + let mut doc = import(&package(&content, None)).unwrap(); + assert_eq!(grid(&doc), vec![72.0, 216.0]); + let widths = vec![150.0, 100.0]; + for b in doc.body.iter_mut() { + if let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b) { + t.grid = widths.clone(); + } + } + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(grid(&back), widths); + } + + #[test] + fn table_without_column_widths_keeps_the_equal_split() { + let content = format!( + r##"AB"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert!(t.widths.is_empty()); + } + #[test] fn table_cell_style_background_is_shading() { // #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading. @@ -2961,6 +3065,23 @@ mod tests { assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]); } + #[test] + fn table_cell_style_vertical_alignment() { + let content = format!( + r##"MidBot"## + ); + let f = parse(&package(&content, None)).unwrap(); + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + assert_eq!(t.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t.rows[0][1].valign, Some(VAlign::Bottom)); + let doc = crate::model::to_doc(&f); + let bytes = export(&doc).unwrap(); + let f2 = parse(&bytes).unwrap(); + let Some(FBlock::Table(t2)) = f2.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table in export") }; + assert_eq!(t2.rows[0][0].valign, Some(VAlign::Center)); + assert_eq!(t2.rows[0][1].valign, Some(VAlign::Bottom)); + } + /// #1379: three master pages (A4 upright; A4 wide with page numbering restarting at 1; A4 /// upright again with numbering continuing) and a two-column `text:section` in between. fn sections_package() -> Vec { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index c6371edb9..45479cca6 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -20,6 +20,7 @@ use std::collections::HashMap; +use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document, Rgb}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img}; @@ -611,6 +612,12 @@ impl Writer { } else if c.rowspan > 1 { out.push_str("\\clvmgf"); } + match c.valign { + Some(VAlign::Top) => out.push_str("\\clvertalt"), + Some(VAlign::Center) => out.push_str("\\clvertalc"), + Some(VAlign::Bottom) => out.push_str("\\clvertalb"), + None => {} + } self.cell_borders(c.borders.as_ref(), t.borderless, out); if let Some(s) = c.shading { let i = self.t.color(s); @@ -987,6 +994,7 @@ struct CellDef { /// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw` /// width and `\brdrcf` colour). borders: wordcraft_doc::props::Borders, + valign: Option, } /// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms). @@ -1826,6 +1834,7 @@ impl Reader { c.shading = d.shading; c.borders = d.borders.any_visible().then_some(d.borders); c.header = self.row_header; + c.valign = d.valign; match d.vmerge { 1 => c.rowspan = 2, 2 => c.covered = true, @@ -2249,6 +2258,9 @@ impl Reader { "clvmrg" => self.def_cur.vmerge = 2, "clmgf" => self.def_cur.hmerge = 1, "clmrg" => self.def_cur.hmerge = 2, + "clvertalt" => self.def_cur.valign = Some(VAlign::Top), + "clvertalc" => self.def_cur.valign = Some(VAlign::Center), + "clvertalb" => self.def_cur.valign = Some(VAlign::Bottom), "clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(), "sb" => self.st.sb = Some(pv), "sa" => self.st.sa = Some(pv), @@ -4371,4 +4383,18 @@ mod tests { assert!(exported.contains("\\qd"), "expected \\qd in: {exported}"); assert!(exported.contains("\\qj"), "expected \\qj in: {exported}"); } + + #[test] + fn table_cell_vertical_alignment() { + let rtf = br"{\rtf1\ansi\trowd\clvertalt\cellx1000\clvertalc\cellx2000\clvertalb\cellx3000\intbl top\cell center\cell bottom\cell\row}"; + let doc = import(rtf).unwrap(); + let wordcraft_doc::Block::Table(t) = &*doc.body[0] else { panic!() }; + assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert_eq!(t.rows[0].cells[1].props.valign, Some(VAlign::Center)); + assert_eq!(t.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); + let exported = export(&doc); + assert!(exported.contains("\\clvertalt")); + assert!(exported.contains("\\clvertalc")); + assert!(exported.contains("\\clvertalb")); + } } diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index afb8f0a4a..172a91cf1 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -13,6 +13,7 @@ wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-proof = { workspace = true } +wordcraft-metafile = { workspace = true } kurbo = { workspace = true } unicode-bidi = { workspace = true } unicode-linebreak = { workspace = true } diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 1c245b885..2bf7a2fdf 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -184,12 +184,13 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> } let img = img.into_rgba8(); let (iw, ih) = img.dimensions(); - let frac = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - let [cl, ct, cr, cb] = crop.map(frac); - let x0 = ((iw as f32 * cl) as u32).min(iw); - let y0 = ((ih as f32 * ct) as u32).min(ih); - let x1 = ((iw as f32 * (1.0 - cr)) as u32).clamp(x0, iw); - let y1 = ((ih as f32 * (1.0 - cb)) as u32).clamp(y0, ih); + // The part the renderers draw (#1899): a sliver keeps its partly covered pixels. + let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop); + let (fw, fh) = (f64::from(iw), f64::from(ih)); + let x0 = ((fw * cl) as u32).min(iw); + let y0 = ((fh * ct) as u32).min(ih); + let x1 = ((fw * (cl + vw)).ceil() as u32).clamp(x0, iw); + let y1 = ((fh * (ct + vh)).ceil() as u32).clamp(y0, ih); let (cw, ch) = (x1 - x0, y1 - y0); if cw == 0 || ch == 0 { return None; @@ -206,9 +207,12 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> .any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128))) }; let pts = alpha_outline(gw as usize, gh as usize, opaque)?; - // The grid may reach a little past the picture (its last cells are partial). - let (sx, sy) = ((gw * cell) as f32 / cw as f32, (gh * cell) as f32 / ch as f32); - Some(pts.into_iter().map(|(x, y)| ((x * sx).min(1.0), (y * sy).min(1.0))).collect()) + // Grid points onto the picture as the renderers draw the window, whose edges fall within + // pixels (crop fractions are f32 too: 0.7 is 0.69999…). The grid may reach a little past the + // pixels (its last cells are partial). + let place = + |g: f32, cells: u32, p0: u32, lo: f64, len: f64| ((f64::from(p0) + f64::from(g) * f64::from(cells * cell) - lo) / len).clamp(0.0, 1.0) as f32; + Some(pts.into_iter().map(|(x, y)| (place(x, gw, x0, fw * cl, fw * vw), place(y, gh, y0, fh * ct, fh * vh))).collect()) } #[cfg(test)] @@ -264,6 +268,39 @@ mod tests { assert!(half.iter().any(|(x, _)| *x > 0.99), "{half:?}"); // No transparency: the rectangle. assert!(picture_outline(&png(image::RgbImage::new(8, 8).into()), [0.0; 4]).is_none()); + // #1899: the sliver the renderers draw (0.5% of the axis, when two sides overlap or keep + // less) has an outline too. Only the bottom-right quarter is opaque, so a column or a row + // through it is opaque in its second half. + let quarter = png(image::RgbaImage::from_fn(100, 100, |x, y| image::Rgba([0, 0, 0, if x >= 50 && y >= 50 { 255 } else { 0 }])).into()); + for (crop, column) in [([0.6, 0.0, 0.6, 0.0], true), ([0.6, 0.0, 0.398, 0.0], true), ([0.0, 0.6, 0.0, 0.6], false)] { + let pts = picture_outline(&quarter, crop).unwrap_or_default(); + let across = |p: &(f32, f32)| if column { p.1 } else { p.0 }; + // The grid's cells are 3 pixels, so the half starts a little before 0.5. + assert!(pts.len() >= 3 && pts.iter().all(|p| across(p) > 0.4), "{crop:?}: {pts:?}"); + } + // Crop fractions are f32 (0.7 is 0.69999…), so an edge on a whole pixel comes out a hair + // off it: the outline neither takes in the neighbouring column nor drops the last one. Only + // the top half of the 30 columns each crop keeps (of 100) is opaque, so it spans 0–1 across. + for (crop, kept) in [([0.7, 0.0, 0.0, 0.0], 70..100), ([0.0, 0.0, 0.7, 0.0], 0..30)] { + let img = image::RgbaImage::from_fn(100, 20, |x, y| image::Rgba([0, 0, 0, if kept.contains(&x) && y < 10 { 255 } else { 0 }])); + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let xs = pts.iter().map(|p| p.0); + let (lo, hi) = (xs.clone().fold(f32::MAX, f32::min), xs.fold(f32::MIN, f32::max)); + assert!(lo < 1e-6 && hi > 1.0 - 1e-6, "{crop:?}: {pts:?}"); + } + // A window whose edges fall within pixels (2.5–5.5 of 10) is placed as the renderers draw + // it: pixel 2, the only opaque one (in half the rows), covers the first sixth of the + // picture, not a quarter (whole pixels 2–5 stretched over it). Across and down alike. + for down in [false, true] { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + let (a, b) = if down { (y, x) } else { (x, y) }; + image::Rgba([0, 0, 0, if a == 2 && b < 5 { 255 } else { 0 }]) + }); + let crop = if down { [0.0, 0.25, 0.0, 0.45] } else { [0.25, 0.0, 0.45, 0.0] }; + let pts = picture_outline(&png(img.into()), crop).unwrap_or_default(); + let far = pts.iter().map(|p| if down { p.1 } else { p.0 }).fold(f32::MIN, f32::max); + assert!(pts.len() >= 3 && (far - 1.0 / 6.0).abs() < 1e-3, "{crop:?}: {pts:?}"); + } } #[test] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 018770b24..db4040ab2 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2970,7 +2970,8 @@ fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> { /// frame (table cells, text boxes, notes) positions relative to the page or margins fall back /// to the box itself. fn float_rect(frame: Option, col: (f32, f32), y0: f32, w: f32, h: f32, float: &Float) -> Rect { - let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); + // Thinner than a point stays thin (a 0.25 pt rule, a flat line): Word keeps the size. + let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0)); // Each axis: the reference area's start and extent, in page coordinates. Across, as laid out // before an even page is mirrored (less the shift it then gets). let page_x = |f: &PageFrame| { diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 8eae57091..5e10224d1 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -392,7 +392,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava } total = tw; } - if total > avail * 1.5 && !t.props.fixed { + // A table with its own width keeps it, however far it runs past the margins (as in Word). + if target.is_none() && total > avail * 1.5 && !t.props.fixed { let k = avail / total.max(1.0); grid.iter_mut().for_each(|w| *w *= k); total = avail; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a59d61b4a..7d3c6b99a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3067,6 +3067,129 @@ fn repeated_header_moves_with_the_first_row_that_does_not_fit() { assert_eq!(rows_per_page(40.0), vec![vec![], vec![0, 1, 2]]); } +#[test] +fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { + // #1704: a table whose repeated header row fits at the bottom of a page while a body row + // does not must not leave the header row there alone. Sweeps filler counts across the page + // boundary for several table geometries (height ratios, a cantSplit body row, an exact + // height body row, two header rows) and pins, for every configuration while the table + // continues onto a next page: no page ends with only header rows, and no row is dropped. + // A table that starts at the very top of a page is exempt (#1705): there the header is the + // table's own beginning, so fillers = 0 skips that assertion. + #[derive(Debug)] + struct Geo { + header: f32, + body: f32, + body_cant_split: bool, + body_exact: bool, + header_rows: usize, + } + let geos = [ + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 1usize), + (Geo { header: 300.0, body: 100.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 50.0, body: 250.0, body_cant_split: false, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: true, body_exact: false, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: true, header_rows: 1 }, 4), + (Geo { header: 100.0, body: 400.0, body_cant_split: false, body_exact: false, header_rows: 2 }, 4), + ]; + // The table, after `fillers` paragraphs: the same table in every configuration. + let table_doc = |fillers: usize, geo: &Geo| -> Document { + let mut d = Document::new(); + for i in 0..fillers { + let mut p = wordcraft_doc::Paragraph::with_text(&format!("Filler {i}"), Default::default()); + p.props.space_after = Some(0.0); + d.body.push(wordcraft_doc::para_block(p)); + } + let mut t = Table::new(geo.header_rows + 2, 1, 468.0); + for (ri, row) in t.rows.iter_mut().enumerate() { + row.props.header = ri < geo.header_rows; + row.props.height = Some(if ri < geo.header_rows { geo.header } else { geo.body }); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + if ri >= geo.header_rows && geo.body_cant_split { + row.props.cant_split = true; + } + if ri >= geo.header_rows && geo.body_exact { + row.props.height_rule = wordcraft_doc::props::HeightRule::Exact; + } + } + d.insert_block(StoryRef::Body, &Path::top(d.body.len()), wordcraft_doc::Block::Table(t)).unwrap(); + d + }; + let pages_with = |fillers: usize, geo: &Geo| -> Vec> { + let l = lay(&table_doc(fillers, geo)); + l.pages + .iter() + .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) + .collect() + }; + // The same table, with the geometry: the (row, frame) of each row's first cell per page, + // and the page's body area, so the room under the header can be measured. + let pages_geom = |fillers: usize, geo: &Geo| -> Vec<(Vec<(usize, Rect)>, Rect)> { + let l = lay(&table_doc(fillers, geo)); + l.pages + .iter() + .map(|p| { + let cells = p + .items + .iter() + .filter_map(|i| match i { + Placed::Cell { rect, row, cell: 0, .. } => Some((*row, *rect)), + _ => None, + }) + .collect(); + (cells, p.body) + }) + .collect() + }; + + for (geo, coarse) in &geos { + for fillers in (0..60).step_by(*coarse) { + let pages = pages_with(fillers, geo); + for w in pages.windows(2) { + // While the table continues on the next page, a page never ends with only + // header rows. (fillers = 0 starts the table at the top of a page: exempt.) + if !w[1].is_empty() && fillers > 0 { + assert!( + w[0].is_empty() || w[0].iter().any(|&r| r >= geo.header_rows), + "fillers {fillers}, geo {geo:?}: a page ends with header rows alone: {pages:?}" + ); + } + } + // No configuration drops rows: every row of the table is placed somewhere. + let placed: std::collections::HashSet = pages.iter().flatten().copied().collect(); + assert_eq!(placed.len(), geo.header_rows + 2, "fillers {fillers}, geo {geo:?}: rows placed: {pages:?}"); + } + } + // Room for the header and the first body row together keeps them on the page, and keeps + // the body row *there*: where the row would fit under the header, it is placed on that + // page instead of being carried over. This is the position, not only the presence -- a + // table that carried a body row over although the page had room for it is the + // unnecessary move the sweep above cannot see. + let mut paired = 0; + for fillers in 1..60 { + let geo = &geos[0].0; + let geom = pages_geom(fillers, geo); + let Some((first_cells, body)) = geom.first() else { continue }; + let Some((_, header_rect)) = first_cells.iter().find(|(r, _)| *r == 0) else { + // The header itself moved to a later page: there is nothing to pair it with here. + continue; + }; + // The room left on the first page under the header. + let room = body.bottom() - header_rect.bottom(); + // The body row's own height, wherever it was placed. + let body_h = geom.iter().flat_map(|(cells, _)| cells.iter()).find(|(r, _)| *r == geo.header_rows).map(|(_, rect)| rect.h).unwrap_or(geo.body); + if room >= body_h { + assert!( + first_cells.iter().any(|(r, _)| *r == geo.header_rows), + "fillers {fillers}: the first body row fits in the {room} pt left under the header \ + (it is {body_h} pt tall) but was carried over to the next page" + ); + paired += 1; + } + } + assert!(paired > 0, "no configuration left room for the header and a body row together"); +} + /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). #[test] fn drop_cap_with_a_huge_font_lays_out() { @@ -9917,3 +10040,70 @@ fn early_float_survives_a_column_break_into_another_width() { assert_eq!(got, want, "second column first: {skip}: the text after the break wraps around nothing"); } } + +/// A table with its own width keeps it when it is over 1.5 times the text width (#1721); one +/// without is still squeezed to the margins. +#[test] +fn table_with_its_own_width_is_not_squeezed_to_the_margins() { + let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); + let second_column_x = |own: Option| { + let mut t = Table::new(1, 3, 936.0); + t.props.width = own; + t.rows[0].cells[1].blocks = vec![text("Address")]; + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let [(x, _, _)] = para_at(&l, &[0, 0, 1, 0])[..] else { panic!("second cell missing") }; + x + }; + // 936pt (twice the 468pt of text) of its own: the columns stay 312pt wide, past the margin. + let x = second_column_x(Some(936.0)); + assert!(x > 72.0 + 300.0, "the second column starts at {x}, squeezed"); + // The same columns with an automatic table width are squeezed to 156pt each. + let x = second_column_x(None); + assert!(x < 72.0 + 200.0, "the second column starts at {x}, not squeezed"); +} + +/// Cut marks: page-anchored rules thinner than a point keep their height (Word draws a 0.25 pt +/// rectangle 0.25 pt high), and a flat `line` stays flat instead of running down a point. +#[test] +fn anchored_objects_thinner_than_a_point_keep_their_size() { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let float = |y: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y, ..Default::default() }; + let mut d = Document::from_text("Rules"); + for (i, h) in [0.25f32, 0.5, 1.0, 2.0].into_iter().enumerate() { + d.insert_object(&Pos::body(0, 0), rect_shape(200.0, h, float(100.0 + 20.0 * i as f32)), &Default::default()).unwrap(); + } + let line = |w: f32, h: f32, y: f32| InlineObject::Shape { + kind: ShapeKind::Line, + w, + h, + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 0.25, + float: float(y), + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), line(200.0, 0.0, 220.0), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), line(0.0, 100.0, 300.0), &Default::default()).unwrap(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let mut rects: Vec<(ShapeKind, Rect)> = + draws.iter().filter_map(|x| if let display::Draw::Shape { rect, kind, .. } = x { Some((*kind, *rect)) } else { None }).collect(); + rects.sort_by(|a, b| a.1.y.total_cmp(&b.1.y)); + let got: Vec<(f32, f32, f32, f32)> = rects.iter().map(|(_, r)| (r.x, r.y, r.w, r.h)).collect(); + let want = [ + (72.0, 100.0, 200.0, 0.25), + (72.0, 120.0, 200.0, 0.5), + (72.0, 140.0, 200.0, 1.0), + (72.0, 160.0, 200.0, 2.0), + (72.0, 220.0, 200.0, 0.0), + (72.0, 300.0, 0.0, 100.0), + ]; + assert_eq!(got.len(), want.len(), "{rects:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-3 && (g.1 - w.1).abs() < 1e-3 && (g.2 - w.2).abs() < 1e-3 && (g.3 - w.3).abs() < 1e-3, "{g:?} != {w:?}"); + } +} diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 0ac4790f6..03a8f3453 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -21,6 +21,12 @@ const R2_NOP: u32 = 11; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; +/// SETTEXTALIGN: draw at and update the current point. +const TA_UPDATECP: u32 = 1; +/// Font height (cell height, logical units) of the default font, used for a height of 0. +const DEFAULT_FONT_HEIGHT: f64 = 16.0; +/// Face used when a font names none. +const DEFAULT_FONT_FACE: &str = "Arial"; /// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size. const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4; @@ -54,11 +60,23 @@ pub(crate) struct Brush { pub color: [u8; 4], } +/// A logical font (EMF LogFont): height in logical units (negative = character height, positive = +/// cell height, 0 = default), escapement in tenths of a degree, face name as UTF-16 (NUL-padded). +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Font { + pub height: i32, + pub escapement: i32, + pub weight: i32, + pub italic: bool, + pub face: [u16; 32], +} + #[derive(Clone, Copy, Debug)] pub(crate) enum Obj { Pen(Pen), Brush(Brush), - /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Font(Font), + /// Palettes, pattern brushes and regions: they take a table slot but draw nothing. Other, } @@ -168,6 +186,23 @@ fn ratio(num: f64, den: f64) -> f64 { pub(crate) enum Raw { Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, Bitmap { rect: [f64; 4], img: Rgba }, + Text(RawText), +} + +/// A text run in device units: the reference point, the font height along the text's up direction, +/// the baseline angle (radians, clockwise in y-down space) and per-character advances. +pub(crate) struct RawText { + pub text: String, + pub origin: P, + pub size: f64, + pub cell: bool, + pub angle: f64, + pub dx: Vec, + pub font: String, + pub bold: bool, + pub italic: bool, + pub color: [u8; 4], + pub align: u32, } #[derive(Clone)] @@ -177,6 +212,9 @@ struct Saved { brush: Brush, even_odd: bool, nop: bool, + font: Option, + text_color: [u8; 4], + text_align: u32, } pub(crate) struct Canvas { @@ -201,6 +239,11 @@ pub(crate) struct Canvas { even_odd: bool, /// The mix mode is R2_NOP: shapes draw nothing. nop: bool, + /// The selected font; None is the default font. + font: Option, + text_color: [u8; 4], + /// SETTEXTALIGN flags (TA_*). + text_align: u32, pos: P, in_path: bool, path: Vec, @@ -224,6 +267,9 @@ impl Canvas { brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, even_odd: true, nop: false, + font: None, + text_color: [0, 0, 0, 255], + text_align: 0, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -240,6 +286,7 @@ impl Canvas { let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), Raw::Bitmap { img, .. } => img.px.len(), + Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::(), }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; @@ -318,7 +365,16 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + self.saved.push(Saved { + map: self.map, + pen: self.pen, + brush: self.brush, + even_odd: self.even_odd, + nop: self.nop, + font: self.font, + text_color: self.text_color, + text_align: self.text_align, + }); } else { self.full = true; } @@ -335,6 +391,9 @@ impl Canvas { self.brush = s.brush; self.even_odd = s.even_odd; self.nop = s.nop; + self.font = s.font; + self.text_color = s.text_color; + self.text_align = s.text_align; } /// Stores an object in `slot` (EMF index), or in the lowest free slot (WMF handle table). @@ -384,6 +443,7 @@ impl Canvas { match self.objects.get(i as usize).copied().flatten() { Some(Obj::Pen(p)) => self.pen = p, Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Font(f)) => self.font = Some(f), Some(Obj::Other) | None => {} } } @@ -402,6 +462,8 @@ impl Canvas { 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + // The stock fonts (OEM, ANSI, system, device default...): the default font. + 10..=17 => self.font = None, _ => {} } } @@ -571,6 +633,57 @@ impl Canvas { } } + pub fn set_text_color(&mut self, c: [u8; 4]) { + self.text_color = c; + } + + pub fn set_text_align(&mut self, a: u32) { + self.text_align = a; + } + + /// Draws `text` at the logical point `at` (the current point with TA_UPDATECP) with the selected + /// font, text colour and alignment. `dx` holds per-character logical advances (empty for the + /// font's own); with TA_UPDATECP the current point moves past them. + pub fn text(&mut self, at: P, text: String, dx: &[f64]) { + if text.is_empty() || self.in_path { + return; + } + let font = self.font.unwrap_or(Font { height: 0, escapement: 0, weight: 400, italic: false, face: [0; 32] }); + let at = if self.text_align & TA_UPDATECP != 0 { self.pos } else { at }; + if self.text_align & TA_UPDATECP != 0 { + self.pos = (at.0 + dx.iter().sum::(), at.1); + } + let height = if font.height == 0 { DEFAULT_FONT_HEIGHT } else { f64::from(font.height) }; + let origin = self.map.device(at); + let len = |v: P| { + let d = self.map.device((at.0 + v.0, at.1 + v.1)); + (d.0 - origin.0).hypot(d.1 - origin.1) + }; + let base = self.map.device((at.0 + 1.0, at.1)); + let escapement = f64::from(font.escapement) / 10.0; + let angle = (base.1 - origin.1).atan2(base.0 - origin.0) - escapement.to_radians(); + let size = len((0.0, height.abs())); + let dx = dx.iter().map(|d| len((*d, 0.0)).copysign(*d)).collect(); + let name = String::from_utf16_lossy(&font.face); + let name = name.trim_end_matches('\0').trim(); + let raw = RawText { + text, + origin, + size, + cell: height > 0.0, + angle: if angle.is_finite() { angle } else { 0.0 }, + dx, + font: if name.is_empty() { DEFAULT_FONT_FACE.to_string() } else { name.to_string() }, + bold: font.weight >= 600, + italic: font.italic, + color: self.text_color, + align: self.text_align, + }; + if raw.origin.0.is_finite() && raw.origin.1.is_finite() && raw.size.is_finite() && raw.size > 0.0 { + self.push(Raw::Text(raw)); + } + } + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the /// bitmap, as the mapping does. pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 904d126b7..4ffe9cd1b 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -2,7 +2,7 @@ //! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::canvas::{BS_NULL, Brush, Canvas, Font, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; use crate::dib::{self, Blend}; use crate::geom::{self, ArcKind, P}; use crate::{Error, Frame, MAX_PATH_OPS}; @@ -15,6 +15,9 @@ const SRCCOPY: u32 = 0x00CC_0020; /// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide. const PS_TYPE_MASK: u32 = 0x000F_0000; const PS_COSMETIC: u32 = 0; +/// EXTTEXTOUT options: the string holds glyph indices, and the dx array holds (x, y) pairs. +const ETO_GLYPH_INDEX: u32 = 0x10; +const ETO_PDY: u32 = 0x2000; pub(crate) fn is_emf(b: Bytes) -> bool { b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE) @@ -100,6 +103,10 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 38 => create_pen(cv, r), 95 => create_ext_pen(cv, r), 39 => create_brush(cv, r), + 82 => create_font(cv, r), + 22 => r.u32(8).map(|a| cv.set_text_align(a)), + 24 => r.u32(8).map(|c| cv.set_text_color(colorref(c))), + 84 => ext_text_out_w(cv, r), 40 => r.u32(8).map(|i| cv.delete_object(i)), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), @@ -250,6 +257,55 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { Some(()) } +/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (height, width, escapement, orientation, weight, +/// italic, ..., face name of 32 UTF-16 units at 40). +fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let mut face = [0u16; 32]; + for (i, u) in face.iter_mut().enumerate() { + *u = r.u16(40 + i * 2).unwrap_or(0); + } + let font = Font { height: r.i32(12)?, escapement: r.i32(20)?, weight: r.i32(28)?, italic: *r.0.get(32)? != 0, face }; + cv.add_object(Some(slot), Obj::Font(font)); + Some(()) +} + +/// EMR_EXTTEXTOUTW: an EmrText at 36 with the reference point, the character count at 44, the string +/// offset at 48, the options at 52 and the dx array offset at 72 (offsets from the record start). +/// Glyph-index strings are skipped: they cannot be mapped back to characters. +fn ext_text_out_w(cv: &mut Canvas, r: Bytes) -> Option<()> { + let at = xy32(r, 36)?; + let n = r.u32(44)? as usize; + let (off, options, off_dx) = (r.u32(48)? as usize, r.u32(52)?, r.u32(72)? as usize); + if options & ETO_GLYPH_INDEX != 0 || n == 0 { + return None; + } + let units: Vec = r.slice(off, n.checked_mul(2)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + // dx has one entry per UTF-16 unit (two with ETO_PDY); a character takes the sum of its units. + let step = if options & ETO_PDY != 0 { 8 } else { 4 }; + let raw_dx: Vec = match r.slice(off_dx, n.checked_mul(step)?) { + Some(b) if off_dx != 0 => b.chunks_exact(step).filter_map(|c| Some(f64::from(i32::from_le_bytes(c.get(..4)?.try_into().ok()?)))).collect(), + _ => Vec::new(), + }; + let mut text = String::new(); + let mut dx = Vec::new(); + let mut i = 0; + for ch in char::decode_utf16(units.iter().copied()) { + let c = ch.unwrap_or(char::REPLACEMENT_CHARACTER); + // A replacement stands for one unpaired unit. + let w = if c == char::REPLACEMENT_CHARACTER { 1 } else { c.len_utf16() }; + if !raw_dx.is_empty() { + dx.push(raw_dx.get(i..i + w).map_or(0.0, |s| s.iter().sum())); + } + i += w; + text.push(c); + } + let text = text.trim_end_matches('\0').to_string(); + dx.truncate(text.chars().count()); + cv.text(at, text, &dx); + Some(()) +} + /// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at /// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead. fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> { diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cc2f64e5c..c63bbb12b 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -1,5 +1,5 @@ //! WordCraft metafiles: reads Windows Metafiles (WMF) and Enhanced Metafiles (EMF) into a neutral -//! vector model of filled and stroked paths and RGBA bitmaps, in a picture frame with y down. +//! vector model of filled and stroked paths, RGBA bitmaps and text runs, in a picture frame with y down. //! //! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is //! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], @@ -21,9 +21,9 @@ mod wmf; mod tests; use bytes::Bytes; -use canvas::Raw; +use canvas::{Raw, RawText}; use geom::{Op, P}; -pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout, crop_window}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; @@ -60,6 +60,46 @@ pub enum Item { }, /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, + /// A run of text; the renderers shape it with the named font (see [`Text::layout`]). + Text(Text), +} + +/// A text run, in picture units. +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + pub text: String, + /// The reference point that `halign` and `valign` refer to. + pub x: f32, + pub y: f32, + /// Font height: the em size, or the cell height (ascent + descent) when `cell` is set. + pub size: f32, + pub cell: bool, + pub font: String, + pub bold: bool, + pub italic: bool, + pub color: [u8; 4], + /// Baseline direction in radians, clockwise from +x (y down). + pub angle: f32, + pub halign: HAlign, + pub valign: VAlign, + /// Advance of each character along the baseline; empty when the font's own advances apply. + pub dx: Vec, +} + +/// Where the reference point sits along the run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HAlign { + Left, + Center, + Right, +} + +/// Where the reference point sits across the run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VAlign { + Top, + Baseline, + Bottom, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -137,11 +177,45 @@ fn build(raw: Vec, frame: Option) -> Picture { let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) } + Raw::Text(t) => text_item(t, tf, sx, sy), }); let items = items.collect(); Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } } +/// A device-space text run mapped into the picture: the reference point through `tf`, lengths by the +/// frame scale. TA_* flags become the alignment. +fn text_item(t: RawText, tf: impl Fn(P) -> P, sx: f64, sy: f64) -> Option { + let (x, y) = tf(t.origin); + let halign = match t.align & 6 { + 2 => HAlign::Right, + 6 => HAlign::Center, + _ => HAlign::Left, + }; + let valign = match t.align & 24 { + 8 => VAlign::Bottom, + 24 => VAlign::Baseline, + _ => VAlign::Top, + }; + let dx = t.dx.iter().map(|d| f(d * sx.abs())).collect::>>()?; + let size = f(t.size * sy.abs()).filter(|s| *s > 0.0)?; + Some(Item::Text(Text { + text: t.text, + x: f(x)?, + y: f(y)?, + size, + cell: t.cell, + font: t.font, + bold: t.bold, + italic: t.italic, + color: t.color, + angle: f(t.angle).unwrap_or(0.0), + halign, + valign, + dx, + })) +} + fn f(v: f64) -> Option { let v = v as f32; v.is_finite().then_some(v) @@ -201,6 +275,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { grow((rect[0], rect[1])); grow((rect[0] + rect[2], rect[1] + rect[3])); } + Raw::Text(t) => grow(t.origin), } } b diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f079553..5a160c4ed 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -3,7 +3,7 @@ use kurbo::{Affine, BezPath, Rect, Shape}; -use crate::{Item, Picture, Seg}; +use crate::{HAlign, Item, Picture, Seg, Text, VAlign}; /// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides. pub const MAX_BITMAP_SIDE: u32 = 30_000; @@ -11,6 +11,25 @@ pub const MAX_BITMAP_SIDE: u32 = 30_000; const MAX_COORD: f64 = 1e6; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; +/// The least of a source axis a crop keeps (0.5%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile, #1899). Layout makes a picture's frame at most 4000 pt +/// a side, so the whole source then spans at most 800,000 pt: what it covers stays inside +/// `MAX_COORD` and is never left out as far off the page. +const MIN_VISIBLE: f64 = 0.005; + +/// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) +/// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.5% of it, +/// the left (top) side stands and 0.5% shows. +pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); + let axis = |lo: f64, hi: f64| { + let lo = lo.min(1.0 - MIN_VISIBLE); + (lo, (1.0 - lo - hi).max(MIN_VISIBLE)) + }; + let ((x, w), (y, h)) = (axis(l, r), axis(t, b)); + [x, y, w, h] +} /// One item of a placed picture, in target units. pub enum PlacedItem { @@ -19,6 +38,72 @@ pub enum PlacedItem { Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, /// `Picture::items[index]`, a bitmap, stretched into `rect`. Bitmap { index: usize, rect: Rect }, + /// `Picture::items[index]`, a text run. `tf` maps the run's own space (picture units, the + /// reference point at the origin, the baseline along +x, y down) to the target. + Text { index: usize, tf: Affine }, +} + +/// A text run laid out with a font's metrics: the em size (in the run's own units, see +/// [`PlacedItem::Text`]), the baseline's y and each glyph's (x, y), alignment included. +pub struct TextLayout { + pub em: f64, + pub baseline: f64, + pub glyphs: Vec<(f64, f64)>, +} + +impl Text { + /// Lays out shaped glyphs, given as (cluster byte offset, x advance, x offset, y offset) in font + /// units of a font with `upem` units per em, `ascent` above and `descent` below the baseline. + /// The per-character advances `dx` place each cluster when there is one per character. + pub fn layout(&self, upem: f64, ascent: f64, descent: f64, glyphs: &[(usize, f64, f64, f64)]) -> Option { + let upem = if upem.is_finite() && upem > 0.0 { upem } else { 1000.0 }; + let cell = ascent + descent; + let size = f64::from(self.size); + let em = if self.cell && cell.is_finite() && cell > 0.0 { size * upem / cell } else { size }; + if !em.is_finite() || em <= 0.0 { + return None; + } + let k = em / upem; + let starts: Vec = self.text.char_indices().map(|(i, _)| i).collect(); + let dx = (self.dx.len() == starts.len() && !starts.is_empty()).then(|| { + let mut cum = Vec::with_capacity(self.dx.len() + 1); + let mut x = 0.0; + cum.push(x); + for d in &self.dx { + x += f64::from(*d); + cum.push(x); + } + cum + }); + let mut out = Vec::with_capacity(glyphs.len()); + let (mut pen, mut cluster_pen, mut last) = (0.0, 0.0, None); + for &(cluster, adv, xo, yo) in glyphs { + let ci = starts.partition_point(|&b| b <= cluster).saturating_sub(1); + if last != Some(ci) { + cluster_pen = pen; + last = Some(ci); + } + let x = match &dx { + Some(cum) => cum.get(ci).copied().unwrap_or(0.0) + pen - cluster_pen, + None => pen, + }; + out.push((x + xo * k, -yo * k)); + pen += adv * k; + } + let width = dx.as_ref().and_then(|c| c.last().copied()).unwrap_or(pen); + let hoff = match self.halign { + HAlign::Left => 0.0, + HAlign::Center => -width / 2.0, + HAlign::Right => -width, + }; + let baseline = match self.valign { + VAlign::Top => ascent * k, + VAlign::Baseline => 0.0, + VAlign::Bottom => -descent * k, + }; + let glyphs = out.into_iter().map(|(x, y)| (x + hoff, y + baseline)).collect(); + Some(TextLayout { em, baseline, glyphs }) + } } impl Picture { @@ -30,10 +115,10 @@ impl Picture { /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, /// bottom) removed first: the visible part fills `r`. None for a degenerate size. pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 }); + let [cl, ct, vw, vh] = crop_window(crop); let (w, h) = (f64::from(self.width), f64::from(self.height)); - let sx = r.width() / (w * (1.0 - cl - cr).max(0.05)); - let sy = r.height() / (h * (1.0 - ct - cb).max(0.05)); + let sx = r.width() / (w * vw); + let sy = r.height() / (h * vh); (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) } @@ -63,6 +148,14 @@ impl Picture { out.push(PlacedItem::Bitmap { index, rect }); } } + Item::Text(t) => { + let (x, y) = (f64::from(t.x), f64::from(t.y)); + let at = tf * kurbo::Point::new(x, y); + if in_range(Rect::new(at.x, at.y, at.x, at.y)) { + let tf = tf * Affine::translate((x, y)) * Affine::rotate(f64::from(t.angle)); + out.push(PlacedItem::Text { index, tf }); + } + } } } out diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 8a93e1411..eaa5b1674 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255 fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { match item { Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), - Item::Bitmap { .. } => panic!("expected a path"), + Item::Bitmap { .. } | Item::Text(_) => panic!("expected a path"), } } @@ -891,6 +891,43 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { assert_eq!(stroke.map(|s| s.1), Some(8.0)); } +/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized +/// metafile), and junk sides give a finite part of the source with some area. +#[test] +fn crop_window_keeps_a_sliver_and_stays_finite() { + let [x, y, w, h] = crate::crop_window([0.0, 0.51771, 0.0, 0.45485]); + assert!(x == 0.0 && w == 1.0 && (y - 0.51771).abs() < 1e-6 && (h - 0.02744).abs() < 1e-5, "{y} {h}"); + let pic = crate::Picture { width: 100.0, height: 1000.0, items: Vec::new() }; + let tf = pic.fit(kurbo::Rect::new(0.0, 0.0, 100.0, 27.44), [0.0, 0.51771, 0.0, 0.45485]).unwrap(); + // The kept band of the source (517.71..545.15) is the frame (0..27.44). + let top = tf * kurbo::Point::new(0.0, 517.71); + let bottom = tf * kurbo::Point::new(0.0, 545.15); + assert!(top.y.abs() < 1e-3 && (bottom.y - 27.44).abs() < 1e-3, "{top:?} {bottom:?}"); + for c in [[f32::NAN, f32::INFINITY, -1.0, 2.0], [0.9; 4], [1.0, 1.0, 0.0, 0.0], [f32::MAX, f32::MIN, f32::NEG_INFINITY, 0.5]] { + let [x, y, w, h] = crate::crop_window(c); + assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + } +} + +/// The narrowest crop on the largest frame layout makes (4000 pt, far down a 22-inch page) still +/// places the source's content: none of it is left out as far off the page. +#[test] +fn the_narrowest_crop_on_the_largest_frame_keeps_its_content() { + let pic = crate::Picture { + width: 100.0, + height: 100.0, + items: vec![ + Item::Bitmap { rect: [0.0, 0.0, 100.0, 100.0], width: 1, height: 1, rgba: vec![255; 4] }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)], fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false }, + ], + }; + let frame = kurbo::Rect::new(1584.0, 1584.0, 5584.0, 5584.0); + // The last sliver, the first sliver, and sides that overlap. + for crop in [[1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0], [0.9999, 0.6, 0.5, 0.9999]] { + assert_eq!(pic.place(frame, crop).len(), 2, "{crop:?}"); + } +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index bcd0c1f57..a19f24b87 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1326,7 +1326,7 @@ impl Exporter<'_> { fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) { let Some(clip) = rect_path(rect) else { return }; if let Some(v) = self.vector(media) { - vector_picture(s, &v, rect, crop, &clip, alpha); + vector_picture(self, s, &v, rect, crop, &clip, alpha); return; } let Some(img) = self.image(media) else { @@ -1336,9 +1336,7 @@ impl Exporter<'_> { s.set_fill(None); return; }; - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop).map(|v| v as f32); let (fw, fh) = (rect.w / vis_w, rect.h / vis_h); let Some(size) = Size::from_wh(fw.max(0.01), fh.max(0.01)) else { return }; let fade = alpha < 0.999; @@ -1665,6 +1663,58 @@ impl Exporter<'_> { Some(o) } + /// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps + /// the run's own space (see [`PlacedItem::Text`]) to the page. + fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) { + let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; + let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); + let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; + let font = self.font(&face); + if font.is_none() && !self.outlined.contains(&face.id()) { + return; + } + let em = lay.em as f32; + let c = tf.as_coeffs().map(|v| v as f32); + if !c.iter().all(|v| v.is_finite()) || !em.is_finite() { + return; + } + let (paint, opacity) = paint_of(t.color); + s.push_transform(&Transform::from_row(c[0], c[1], c[2], c[3], c[4], c[5])); + s.set_stroke(None); + s.set_fill(Some(Fill { paint: paint.into(), opacity, rule: FillRule::NonZero })); + match font { + Some(font) => { + let upem = face.upem.max(1.0) as f32; + let kg: Vec = shaped + .iter() + .enumerate() + .map(|(i, g)| { + let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(t.text.len()); + let start = g.cluster.min(end); + let x = lay.glyphs.get(i).map_or(0.0, |p| p.0); + let adv = match lay.glyphs.get(i + 1) { + Some(n) => ((n.0 - x) / lay.em) as f32, + None => g.x_advance as f32 / upem, + }; + let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32); + KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None) + }) + .collect(); + let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32; + s.draw_glyphs(Point::from_xy(x0, lay.baseline as f32), &kg, font, &t.text, em, false); + } + None => { + let pen: Vec<(u32, f32, f32)> = shaped.iter().zip(&lay.glyphs).map(|(g, (x, y))| (g.gid, *x as f32, *y as f32)).collect(); + if let Some(path) = glyph_outlines(&face, em, &pen) { + s.draw_path(&path); + } + } + } + s.set_fill(None); + s.pop(); + } + fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32) { let r = wordcraft_fonts::word::resolve(&wm.font, false, false); let face = r.face; @@ -1757,8 +1807,9 @@ const HAIRLINE: f64 = 0.25; /// Draws a metafile picture into the clip (the picture frame): paths are filled and stroked in page /// space (strokes keep their width under non-uniform scaling), bitmaps stretched, all faded by `alpha`. -/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. -fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) { +/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. Text +/// runs are drawn as text. +fn vector_picture(ex: &mut Exporter, s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) { let target = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x + r.w), f64::from(r.y + r.h)); let fade = alpha < 0.999; if fade { @@ -1796,6 +1847,11 @@ fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: s.draw_image(img.clone(), size); s.pop(); } + PlacedItem::Text { index, tf } => { + if let Some(wordcraft_metafile::Item::Text(t)) = v.pic.items.get(index) { + ex.vector_text(s, t, tf); + } + } } } s.set_fill(None); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index c9c239908..e930f55c5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -959,6 +959,66 @@ fn metafile_pictures_are_drawn_as_vector_paths() { assert!(!raw.contains("0.8156"), "the grey placeholder box is not drawn"); } +/// #1899: a picture cropped to a sliver of its source (3% across here) is drawn at that sliver's +/// size, bitmap or metafile: the source spans 1/0.03 frames, not at most 20 (the old 5% floor). +#[test] +fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + /// The widest image and the widest fill drawn, in page units. + #[derive(Default)] + struct Widest(f64, f64); + impl Device<'_> for Widest { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, _: &Paint<'_>, mode: &PathDrawMode) { + if matches!(mode, PathDrawMode::Fill(_)) { + self.1 = self.1.max(kurbo::Shape::bounding_box(&(transform * path.clone())).width()); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, transform: kurbo::Affine) { + self.0 = self.0.max(transform.as_coeffs()[0].abs()); + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let widest = |bytes: Vec, ext: &str, crop: [f32; 4]| { + let mut d = Document::new(); + let media = d.add_media(bytes, ext); + let mut p = Paragraph::new(); + p.insert_object( + 0, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop, ole: None }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let pdf = Pdf::new(export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap()).expect("parse"); + let cache = InterpreterCache::new(); + let mut w = Widest::default(); + for page in pdf.pages().iter() { + let mut ctx = + Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut w); + } + w + }; + let sliver = [0.47, 0.0, 0.5, 0.0]; + // The bitmap: the whole image drawn behind the frame's clip. + let (whole, cropped) = (widest(png(), "png", [0.0; 4]).0, widest(png(), "png", sliver).0); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "png: {whole} → {cropped}"); + // The metafile: its red right half, a filled path. + let (whole, cropped) = (widest(red_right_half_emf(), "emf", [0.0; 4]).1, widest(red_right_half_emf(), "emf", sliver).1); + assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}"); +} + /// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback. #[test] fn inline_picture_figure_keeps_its_alt_text() { @@ -1143,6 +1203,44 @@ fn cosmetic_metafile_pens_are_thin_but_visible() { assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide"); } +/// EMF text records: EXTCREATEFONTINDIRECTW (Arial, 20 units high) in slot 1, SELECTOBJECT, +/// SETTEXTCOLOR (black), then EXTTEXTOUTW `text` at (5, 40) with no dx array. +fn emf_text_records(text: &str) -> Vec> { + let mut font = [82i32.to_le_bytes(), 104u32.to_le_bytes(), 1i32.to_le_bytes(), (-20i32).to_le_bytes()].concat(); + font.extend([0i32, 0, 0, 400, 0, 0].iter().flat_map(|v| v.to_le_bytes())); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + let units: Vec = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let padded = units.len().div_ceil(4) * 4; + let mut out = [84u32.to_le_bytes(), ((76 + padded) as u32).to_le_bytes()].concat(); + out.extend([0i32, 0, 100, 100, 1, 0, 0, 5, 40, text.encode_utf16().count() as i32, 76, 0, 0, 0, 0, 0, 0].iter().flat_map(|v| v.to_le_bytes())); + out.extend(units); + out.resize(76 + padded, 0); + vec![font, emf_rec(37, &[1]), emf_rec(24, &[0]), out] +} + +/// #1764: the text of an EMF picture (EXTTEXTOUTW) is drawn, so it is in the PDF's text layer. +#[test] +fn metafile_text_records_are_drawn_as_text() { + let mut recs = emf_text_records("Cell42"); + recs.push(emf_rec(14, &[0; 3])); + let mut d = Document::new(); + let media = d.add_media(emf_file(&recs), "emf"); + let mut p = Paragraph::with_text("Figure: ", CharProps::default()); + let n = p.len(); + p.insert_object( + n, + InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap(); + let text = extract_text(&bytes).concat(); + assert!(text.contains("Cell42"), "the metafile's text is drawn: {text:?}"); +} + /// #455: a link to a bookmark after a page break inside its paragraph goes to the page the /// bookmark is on, not the paragraph's first page. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5b450f04..8610dd1f9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -283,6 +283,7 @@ impl Vector { .map(|it| match it { Item::Path { segs, .. } => segs.len() * std::mem::size_of::(), Item::Bitmap { .. } => 0, + Item::Text(t) => t.text.len() + t.dx.len() * 4, }) .sum(); px + segs @@ -361,6 +362,12 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32 ctx.fill_rect(&rect); ctx.reset_paint_transform(); } + PlacedItem::Text { index, tf } => { + if let Some(Item::Text(t)) = v.pic.items.get(index) { + draw_vector_text(ctx, t, view * tf); + } + ctx.set_transform(view); + } } } ctx.set_fill_rule(peniko::Fill::NonZero); @@ -370,6 +377,22 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32 } } +/// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device. +fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) { + let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; + let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); + let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; + let k = lay.em / face.upem.max(1.0); + ctx.set_paint(rgba_color(t.color)); + let db = FontDb::global(); + for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { + let o = db.outline(&face, g.gid); + ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k)); + ctx.fill_path(&o); + } +} + /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. fn device_pixel(view: Affine) -> f64 { let c = view.as_coeffs(); @@ -907,9 +930,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl return; }; let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); - let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) as f64 } else { 0.0 }); - let vis_w = (1.0 - cl - cr).max(0.05); - let vis_h = (1.0 - ct - cb).max(0.05); + let [cl, ct, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop); let sx = r.width() / (pw * vis_w); let sy = r.height() / (ph * vis_h); ctx.set_transform(view); @@ -1888,6 +1909,50 @@ mod tests { assert!(left[..3].iter().all(|c| *c > 200), "left half should be paper, not the grey box: {left:?}"); } + /// #1899: a crop that keeps a sliver of the picture (3% here; a header's bar can be 2.7% of a + /// page-sized metafile) stretches that sliver over the frame, as the file says, for vector and + /// bitmap pictures alike. Both pictures are paper on the left half and red on the right. + #[test] + fn a_crop_keeping_a_sliver_fills_the_frame_with_it() { + let mut png = Vec::new(); + image::RgbaImage::from_fn(100, 100, |x, _| image::Rgba(if x >= 50 { [255, 0, 0, 255] } else { [255; 4] })) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + for (bytes, ext) in [(red_right_half_emf(), "emf"), (png, "png")] { + // (crop, where in the frame, red?): 47–50% of the source is all paper; 50–53% all red. + for (crop, at, red) in [([0.47, 0.47, 0.5, 0.5], 0.7, false), ([0.5, 0.47, 0.47, 0.5], 0.3, true)] { + let mut d = Document::new(); + let media = d.add_media(bytes.clone(), ext); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop, + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let rect = page_display(&d, &l.pages[0], &opts.display) + .iter() + .find_map(|i| match i { + Draw::Image { rect, .. } => Some(*rect), + _ => None, + }) + .expect("a picture on the page"); + let img = render_page(&d, &l.pages[0], 1.0, &opts); + let px = img.pixel((rect.x + rect.w * at) as u32, (rect.y + rect.h * at) as u32); + let is_red = px[0] > 200 && px[1] < 60 && px[2] < 60; + let is_paper = px[..3].iter().all(|c| *c > 200); + assert!(if red { is_red } else { is_paper }, "{ext} crop {crop:?} at {at}: {px:?}"); + } + } + } + #[test] fn cosmetic_metafile_pen_is_one_device_pixel_wide() { // MOVETO (0, 50), LINETO (100, 50) with the default cosmetic black pen: a hairline across the middle. @@ -1924,6 +1989,52 @@ mod tests { assert!(darkest < 140, "the hairline should be clearly visible: {darkest}"); } + /// #1764: an EMF picture's text (EXTCREATEFONTINDIRECTW, SELECTOBJECT, SETTEXTCOLOR, EXTTEXTOUTW) + /// is painted, not dropped. + #[test] + fn metafile_text_records_are_painted() { + let mut font = emf_rec(82, &le32(&[1, -20, 0, 0, 0, 700, 0, 0])); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + font[4..8].copy_from_slice(&104u32.to_le_bytes()); + // "WWWW" at (5, 40), top-aligned, no dx array. + let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0])); + text.extend("WWWW".encode_utf16().flat_map(|u| u.to_le_bytes())); + text[4..8].copy_from_slice(&84u32.to_le_bytes()); + let mut emf = red_right_half_emf()[..88].to_vec(); + emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat()); + let mut d = Document::new(); + let media = d.add_media(emf, "emf"); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let opts = RenderOptions::default(); + let rect = page_display(&d, &l.pages[0], &opts.display) + .iter() + .find_map(|i| match i { + Draw::Image { rect, .. } => Some(*rect), + _ => None, + }) + .expect("a picture on the page"); + let scale = 2.0; + let img = render_page(&d, &l.pages[0], scale, &opts); + let (x0, y0) = ((rect.x * scale) as u32, (rect.y * scale) as u32); + let (x1, y1) = (((rect.x + rect.w) * scale) as u32, ((rect.y + rect.h) * scale) as u32); + let dark = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count(); + assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels"); + } + #[test] fn unreadable_metafile_keeps_the_grey_box() { let mut d = Document::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb76066de..8ec2a5d3f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2434,7 +2434,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo return; } if resp.triple_clicked() { - let _ = app.run("select.paragraph", json!({})); + let _ = app.run("select.paragraph", json!({"cell": true})); return; } let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() && !over_balloon(app, p); diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index b24d483a2..05dec4d29 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -19,8 +19,8 @@ use crate::WordApp; use crate::frame::{self, Handle}; use crate::theme::Tokens; -/// The most of the source one side can crop away (as `picture.crop` clamps). -const MAX_SIDE: f32 = 0.45; +/// The most of the source one side can crop away (as `picture.crop` and the renderer clamp). +const MAX_SIDE: f32 = 0.95; /// Pointer travel that turns a press on a handle into a drag (screen points). const DRAG_START: f32 = 3.0; /// Crop handles: arm length of a corner's L, length of an edge bar, thickness (screen points). @@ -51,10 +51,10 @@ const MAX_SIZE: f32 = 4000.0; /// stops there), unless it was already outside it. fn axis(len: f32, lo: f32, hi: f32, grab: i8, d: f32) -> (f32, f32, f32, f32) { // The source's full length at this scale; the visible part is 1-lo-hi of it. - let full = len / (1.0 - lo - hi).max(0.1); + let full = len / (1.0 - lo - hi).max(0.05); // How far the grabbed side's crop may go: at most MAX_SIDE, and the length within its range. let range = |cur: f32, other: f32| { - let most = (1.0 - other - MIN_SIZE / full).min(MAX_SIDE).max(cur); + let most = (1.0 - other - MIN_SIZE / full).min(MAX_SIDE - other).max(cur); let least = (1.0 - other - MAX_SIZE / full).max(0.0).min(cur); (least, most) }; @@ -316,8 +316,8 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p // The crop as it shows: a flipped picture shows its source's right on the left. let c = on_screen(c, flip_h, flip_v); // The whole source, at the picture's scale, around the visible part. - let fw = frame_pt.w / (1.0 - c[0] - c[2]).max(0.1); - let fh = frame_pt.h / (1.0 - c[1] - c[3]).max(0.1); + let fw = frame_pt.w / (1.0 - c[0] - c[2]).max(0.05); + let fh = frame_pt.h / (1.0 - c[1] - c[3]).max(0.05); let full_pt = wordcraft_geom::Rect::new(frame_pt.x - c[0] * fw, frame_pt.y - c[1] * fh, fw, fh); let (Some(f), Some(full)) = (screen(pages, scale, page, frame_pt), screen(pages, scale, page, full_pt)) else { return false }; let tex = source(app, painter.ctx(), &media); @@ -410,9 +410,9 @@ mod tests { #[test] fn crops_are_clamped_and_never_go_past_the_source() { - // At most 0.45 a side. + // At most 0.95 a side. let r = cropped(200.0, 100.0, [0.0; 4], H(-1, 0), 1000.0, 0.0); - assert!((r.crop[0] - 0.45).abs() < 1e-4 && (r.w - 110.0).abs() < 1e-3 && (r.dx - 90.0).abs() < 1e-3, "{r:?}"); + assert!((r.crop[0] - 0.95).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3 && (r.dx - 190.0).abs() < 1e-3, "{r:?}"); // Out past the original edge: back to uncropped, no further. let r = cropped(160.0, 100.0, [0.0, 0.0, 0.2, 0.0], H(1, 0), 100.0, 0.0); assert!(close(r, Cropped { crop: [0.0; 4], w: 200.0, h: 100.0, dx: 0.0, dy: 0.0 }), "{r:?}"); @@ -420,6 +420,18 @@ mod tests { assert!(close(r, Cropped { crop: [0.0; 4], w: 200.0, h: 100.0, dx: 0.0, dy: -10.0 }), "{r:?}"); } + /// #3135: a side can take almost the whole source (5% stays), so any part of the picture can + /// be kept, not only its middle; the two sides of an axis keep their 5%. + #[test] + fn either_side_can_crop_almost_all_the_source_away() { + // Drag the left handle 190 pt across a 200 pt picture: 10 pt (5%) stay. + let r = cropped(200.0, 100.0, [0.0; 4], H(-1, 0), 190.0, 0.0); + assert!((r.crop[0] - 0.95).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3 && (r.dx - 190.0).abs() < 1e-3, "{r:?}"); + // The right side already takes 0.6: the left stops at 0.35, leaving 5%. + let r = cropped(80.0, 100.0, [0.0, 0.0, 0.6, 0.0], H(-1, 0), 1000.0, 0.0); + assert!((r.crop[0] - 0.35).abs() < 1e-4 && (r.crop[2] - 0.6).abs() < 1e-4 && (r.w - 10.0).abs() < 1e-3, "{r:?}"); + } + #[test] fn dragging_back_out_restores_what_was_cropped() { let a = cropped(200.0, 100.0, [0.0; 4], H(1, 1), -30.0, -20.0); @@ -439,9 +451,9 @@ mod tests { assert_eq!(cropped(0.0, 100.0, [0.0; 4], H(-1, 0), 10.0, 0.0), same(0.0, 100.0, [0.0; 4])); assert_eq!(cropped(-5.0, 100.0, [0.0; 4], H(1, 1), 10.0, 10.0), same(-5.0, 100.0, [0.0; 4])); assert_eq!(cropped(200.0, f32::NAN, [0.0; 4], H(0, 1), 0.0, 10.0), same(200.0, 0.0, [0.0; 4])); - // A junk crop reads as uncropped / at most 0.45 a side; a zero drag leaves that be. + // A junk crop reads as uncropped / at most 0.95 a side; a zero drag leaves that be. let r = cropped(200.0, 100.0, [f32::NAN, -1.0, 0.7, 0.0], H(-1, -1), 0.0, 0.0); - assert_eq!(r, same(200.0, 100.0, [0.0, 0.0, 0.45, 0.0])); + assert_eq!(r, same(200.0, 100.0, [0.0, 0.0, 0.7, 0.0])); } /// Sizes stay within what `picture.crop` accepts (1–4000 pt) at the same scale, so the diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d7d695600..3a945bf07 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5107,4 +5107,55 @@ mod tests { assert!(pages[0].footer_story.is_none(), "the title page shows no page number"); assert!(pages[1].footer_story.is_some(), "the next page keeps its number"); } + + /// #2701: in the SmartArt Text Pane, Tab demotes and Shift+Tab promotes the focused item, as + /// Word's pane does; the row keeps its focus, so the next Tab acts on the same item. An item + /// that can't go down a level (the one before it isn't deeper) is left as it is. + #[test] + fn text_pane_tab_demotes_and_shift_tab_promotes_the_focused_item() { + use egui_kittest::kittest::Queryable; + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("insert.smartArt", json!({"layout": "hierarchy"})).unwrap(); + app.run("smartArt.textPane", json!({"value": true})).unwrap(); + let levels = |app: &WordApp| { + wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).map(|g| g.items.iter().map(|i| i.level).collect::>()) + }; + assert_eq!(levels(&app), Some([0, 1, 2, 1, 2, 2].to_vec()), "Lead, Design, Sketches, Build, Code, Tests"); + let mut h = app_harness(app); + h.run_steps(2); + // Focus a pane row: the text box with this value (the graphic drawn on the page shows the + // same words as shape names, so match by role). + let focus_row = |h: &mut egui_kittest::Harness<'static, WordApp>, text: &str| { + let node = h + .query_all(egui_kittest::kittest::By::new().role(egui::accesskit::Role::TextInput).value(text)) + .next() + .unwrap_or_else(|| panic!("no text box for {text}")); + node.focus(); + h.run_steps(2); + }; + let tab = |h: &mut egui_kittest::Harness<'static, WordApp>, shift: bool| { + if shift { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + } else { + h.key_press(egui::Key::Tab); + } + h.run_steps(2); + }; + // Focus "Design" (the second top-level item, at level 1): Tab can't demote it, because + // the item before it ("Lead") is shallower — Word leaves the level alone. + focus_row(&mut h, "Design"); + assert_eq!(h.state().smart_art_item, Some(1), "the pane remembers the focused row"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 1, 2, 2].to_vec()), "Tab on an item that can't go down changes nothing"); + assert_eq!(h.state().smart_art_item, Some(1), "the row keeps focus after a refused demote"); + // Focus "Build" (level 1 after the deeper "Sketches"): Tab demotes it and its sub-items, + // and the next Tab (the row stayed focused) demotes them again; Shift+Tab promotes back. + focus_row(&mut h, "Build"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Tab demotes the focused item"); + tab(&mut h, false); + assert_eq!(levels(h.state()), Some([0, 1, 2, 3, 4, 4].to_vec()), "the same row takes the next Tab"); + tab(&mut h, true); + assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Shift+Tab promotes them back"); + } } diff --git a/crates/ui-egui/src/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs_smart_art.rs index d7f0e7824..977c184be 100644 --- a/crates/ui-egui/src/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs_smart_art.rs @@ -323,7 +323,8 @@ pub fn type_into(app: &mut WordApp, typed: &str) -> bool { } /// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one -/// undo step per item typed in); Enter adds an item after the current one. +/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the +/// focused item and Shift+Tab promotes it, as Word's Text Pane does. pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return }; let t = Tokens::get(ui.ctx()); @@ -332,6 +333,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { let focus_id = egui::Id::new("smart_art_focus"); let want_focus: Option = ui.data_mut(|d| d.remove_temp(focus_id)); let mut typed = false; + app.smart_art_row_focused = false; egui::ScrollArea::vertical().id_salt("smart_art_items").max_height((ui.available_height() - 40.0).max(60.0)).show(ui, |ui| { for (i, it) in g.items.iter().enumerate() { ui.horizontal(|ui| { @@ -344,6 +346,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { } if r.has_focus() || r.gained_focus() { app.smart_art_item = Some(i); + app.smart_art_row_focused = true; } if r.changed() { typed = true; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cedaa448..369f96a57 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -600,6 +600,9 @@ pub struct WordApp { /// SmartArt Text Pane: the item last focused (Promote, Demote and Add Shape act on it) and /// the item being typed in (its keystrokes join one undo step). pub smart_art_item: Option, + /// A Text Pane row has focus (`smart_art_item` remembers the last one, so Tab handling in + /// [`WordApp::logic`] only fires while a row is focused, not from the canvas). + pub smart_art_row_focused: bool, pub(crate) smart_art_typing: Option, /// The tab shown before Outline view brought up the Outlining tab (restored when it closes). pub(crate) outline_prev_tab: Option, @@ -714,6 +717,7 @@ impl WordApp { equation_prev_tab: None, smart_art_item: None, smart_art_typing: None, + smart_art_row_focused: false, outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, @@ -1997,6 +2001,17 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's + // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does + // resetting the focus direction: the item keeps focus after its level changes. + if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } + } // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index dde825b46..78e547811 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -468,10 +468,11 @@ fn behind_drag_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, .map(|o| (o, Grab::Move)) } -/// Whether a drag shows the object's pixels (moved, or scaled for an unturned picture) rather -/// than an outline: resizing reflows a text box (and redraws a shape). +/// Whether a drag shows the object's pixels rather than an outline. Resizing reflows a text +/// box (and redraws a shape). Text-box text doesn't yet follow its frame's committed rotation, +/// so turning one keeps the outline preview rather than promising rotated text on release. fn shows_pixels(d: &ObjectDrag) -> bool { - (d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move + (d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none()) } /// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points @@ -538,6 +539,18 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p if let Some(d) = app.canvas.obj_drag.as_ref().filter(|d| d.drag.moved) { let Some(to) = screen(pages, scale, d.drag.to_page, d.drag.rect) else { return }; if d.drag.grab == Grab::Rotate { + if let Some(tex) = &d.preview + && let Some(bounds) = screen(pages, scale, d.drag.page, d.object.bounds()) + { + // The cached pixels already contain the starting rotation and flips. Turn + // only by the additional angle; changing UVs would mirror them a second time. + let mut mesh = egui::Mesh::with_texture(tex.id()); + let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + mesh.add_rect_with_uv(bounds, uv, Color32::from_white_alpha(PREVIEW_OPACITY)); + let delta = wordcraft_geom::normalize_degrees(d.drag.deg - d.object.spin.deg); + mesh.rotate(egui::emath::Rot2::from_angle(delta.to_radians()), to.center()); + painter.add(egui::Shape::mesh(mesh)); + } frame::paint_turning(painter, t, to, d.drag.deg); return; } @@ -1096,4 +1109,168 @@ mod tests { assert_eq!(o.page, 1, "dropped on the page under the pointer, not thrown back"); assert!((o.rect.x - 100.0).abs() < 0.5, "kept its place across: {:?}", o.rect); } + + /// A settled canvas for the rotation regressions, without taking focus on the desktop. + fn rotation_window(app: WordApp) -> crate::table_resize::tests::H { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + crate::table_resize::tests::steps(&mut h, 6); + h + } + + /// #2277: the actual picture pixels follow the rotation handle before release, including + /// pictures whose cached preview already contains a rotation and either reflection. + #[test] + fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() { + use crate::table_resize::tests::{H, button, steps}; + for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] { + // Four original colour quadrants make both accidental extra rotation and reflected + // UVs observable. Sample near the long ends after a quarter turn, outside the old + // picture, so the stationary page image cannot satisfy the pixel assertions. + let colours = [[230, 20, 20, 255], [20, 200, 20, 255], [20, 20, 230, 255], [230, 200, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| { + let quadrant = match (x < 40, y < 20) { + (true, true) => 0, + (false, true) => 1, + (false, false) => 2, + (true, false) => 3, + }; + image::Rgba(colours[quadrant]) + }); + let mut bytes = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let picture = InlineObject::Image { + media: "rotation-test".into(), + w: 200.0, + h: 100.0, + alt: String::new(), + float: wordcraft_doc::para::Float { + wrap: Wrap::InFrontOfText, + x: 150.0, + y: 180.0, + rot: initial, + flip_h, + flip_v, + ..Default::default() + }, + crop: [0.0; 4], + ole: None, + }; + let (mut app, _) = app_with(picture); + app.session.doc.media.insert("rotation-test".into(), Arc::new(bytes)); + app.session.touch(); + app.run("view.commentsPane", json!({"value": false})).unwrap(); + app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap(); + let mut h: H = rotation_window(app); + let depth = h.state().session.undo_depth(); + let original = serde_json::to_value(&h.state().session.doc).unwrap(); + let o = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap(); + let (cx, cy) = (o.rect.x + o.rect.w / 2.0, o.rect.y + o.rect.h / 2.0); + let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap(); + let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, o.rect.y).unwrap(); + let radius = center.distance(top) + 22.0; + let handle = |degrees: f32| center + egui::emath::Rot2::from_angle(degrees.to_radians()) * egui::vec2(0.0, -radius); + let target = wordcraft_geom::normalize_degrees(initial + 90.0); + let start = handle(initial); + let end = handle(target); + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + let d = h.state().canvas.obj_drag.as_ref().expect("rotating"); + assert_eq!(d.drag.grab, Grab::Rotate); + assert_eq!(d.drag.deg, target); + let texture = d.preview.as_ref().expect("cached picture preview").id(); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "dragging must not edit the document"); + assert_eq!(h.state().session.undo_depth(), depth); + let rendered = h.render().unwrap(); + if initial == 0.0 + && let Ok(path) = std::env::var("WORDCRAFT_ROTATION_SHOT") + { + rendered.save(path).unwrap(); + } + let ppp = h.ctx.pixels_per_point(); + let spin = wordcraft_geom::Spin::new(target, flip_h, flip_v); + for ((x, y), colour) in [(-75.0, -20.0), (75.0, -20.0), (75.0, 20.0), (-75.0, 20.0)].into_iter().zip(colours) { + let (x, y) = spin.apply(cx, cy, cx + x, cy + y); + let at = crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap(); + let pixel = rendered.get_pixel((at.x * ppp).round() as u32, (at.y * ppp).round() as u32).0; + for (actual, expected) in pixel.into_iter().zip(colour).take(3) { + if expected > 150 { + assert!(actual > 170, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}"); + } else { + assert!(actual < 150, "live quadrant missing: {initial}/{flip_h}/{flip_v} {colour:?} -> {pixel:?}"); + } + } + } + // A second angle reuses the raster rather than regenerating it on pointer moves. + let next = handle(target + 15.0); + h.event(egui::Event::PointerMoved(next)); + steps(&mut h, 2); + assert_eq!(h.state().canvas.obj_drag.as_ref().unwrap().preview.as_ref().unwrap().id(), texture); + let final_angle = h.state().canvas.obj_drag.as_ref().unwrap().drag.deg; + button(&h, next, false); + steps(&mut h, 3); + assert!(h.state().canvas.obj_drag.is_none()); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one command on release"); + assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, final_angle); + h.state_mut().run("edit.undo", json!({})).unwrap(); + steps(&mut h, 3); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "one undo restores everything"); + + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + assert!(h.state().canvas.obj_drag.as_ref().unwrap().preview.is_some()); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + button(&h, end, false); + steps(&mut h, 3); + assert!(h.state().canvas.obj_drag.is_none()); + assert_eq!(h.state().session.undo_depth(), depth, "Escape adds no undo step"); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original, "Escape cancels without an edit"); + } + } + + #[test] + fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() { + use crate::table_resize::tests::{button, steps}; + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), Default::default()); + app.run("insert.textBox", json!({"text": "Text stays upright on release"})).unwrap(); + let bx = app.session.layout().find_object(0, |o| o.text_box.is_some()).unwrap(); + app.run("select.range", json!({"anchor": bx.pos(), "focus": Pos { off: bx.off + OBJ.len_utf8(), ..bx.pos() }})).unwrap(); + app.run("arrange.bounds", json!({"page": 0, "x": 150, "y": 180, "width": 200, "height": 100})).unwrap(); + let mut h = rotation_window(app); + let original = serde_json::to_value(&h.state().session.doc).unwrap(); + let depth = h.state().session.undo_depth(); + let bx = h.state_mut().session.layout().find_object(0, |o| o.text_box.is_some()).unwrap(); + let (cx, cy) = (bx.rect.x + bx.rect.w / 2.0, bx.rect.y + bx.rect.h / 2.0); + let center = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap(); + let top = crate::canvas::page_to_screen(h.state_mut(), 0, cx, bx.rect.y).unwrap(); + let radius = center.distance(top) + 22.0; + let (start, end) = (center - egui::vec2(0.0, radius), center + egui::vec2(radius, 0.0)); + h.event(egui::Event::PointerMoved(start)); + button(&h, start, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + steps(&mut h, 2); + let d = h.state().canvas.obj_drag.as_ref().expect("turning the text box frame"); + assert_eq!((d.drag.grab, d.drag.deg), (Grab::Rotate, 90.0)); + assert!(d.preview.is_none(), "do not preview rotated text that would snap upright on release"); + assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), original); + assert_eq!(h.state().session.undo_depth(), depth); + button(&h, end, false); + steps(&mut h, 3); + assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0); + assert_eq!(h.state().session.undo_depth(), depth + 1); + } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 00bc18d9a..c4408f770 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2704,13 +2704,16 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { for (i, side) in ["L", "T", "R", "B"].iter().enumerate() { ui.label(egui::RichText::new(tl!(side)).small()); let mut v = crop0[i] * 100.0; - let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=45.0).suffix("%")); + let r = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(0.0..=95.0).suffix("%")); if r.changed() { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); } let mut c = crop0; - c[i] = (v / 100.0).clamp(0.0, 0.45); + c[i] = (v / 100.0).clamp(0.0, 0.95); + // The two sides of an axis keep 5% of the source between them. + let other = c[if i < 2 { i + 2 } else { i - 2 }]; + c[i] = c[i].min((0.95 - other).max(0.0)); let _ = app.run("picture.crop", json!({"left": c[0], "top": c[1], "right": c[2], "bottom": c[3]})); } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9ab6408b0..eeff547a1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -12,6 +12,13 @@ gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. +## Drawing payloads + +DOCX textboxes retain their own frame and story when they contain an inline picture. +The direct WPS payload takes precedence over picture blips inside its text. Textbox fitting +and floating-child paint require separate qualification. A shape's own picture fill retains +its existing image fallback. + ## Legacy DOC/DOT document properties The binary Word reader imports SummaryInformation text properties: title, subject, author, @@ -235,6 +242,8 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `basereading`, in the base text's run formatting, and HTML import reads ``/``/`` back into one ruby object: `` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | | 2026-10-21 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. The decoder does no BOM sniffing, so a run that starts `EF BB BF` in GBK, Big5 or UHC stays text, and ASCII runs and picture hex no longer decode byte by byte. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are unchanged | | 2026-10-21 | trivial | Tables: table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are written for our banded styles and read with zero kept; a style without them has no bands, as in Word, so banded rows shade the same rows in both (#3415) | +| 2026-10-21 | trivial | ODT: `table:table-column` styles (`style:column-width`, `table:number-columns-repeated`) are read as the table's grid, so column widths set in LibreOffice or changed in WordCraft survive a save; before, every opened table got equal columns and the written widths were dropped on the next open | +| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. | | 2026-10-19 | trivial | An equation from LibreOffice in an `.odt` file no longer shows blank: it's embedded as a `draw:object` formula sub-document (MathML), with a `draw:image` replacement preview in LibreOffice's own metafile format WordCraft can't decode as a picture; the formula's own `content.xml` is read instead, through the same MathML reader as HTML (#3099) | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | @@ -264,6 +273,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-16 | trivial | Positional tabs (`w:ptab`) keep their alignment, base and leader through DOCX saves instead of becoming plain tabs (#2367) | | 2026-10-16 | trivial | PDF outline destinations point to heading text after leading page breaks, including selected-page exports | | 2026-10-16 | trivial | `w:eastAsianLayout` (two lines in one, horizontal in vertical) is read and written; not laid out yet | +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-15 | trivial | A group holding Word ink can't be ungrouped (its ink would be lost on save) | | 2026-10-15 | trivial | Native ink nested in groups and drawing canvases kept on save, in its place, drawn from its fallback picture | | 2026-10-18 | trivial | DOCX: every VML shape anchored in a header table cell is placed on the page whatever `o:allowincell` says (user-checked in Word for Mac, #1775) | diff --git a/docs/gaps.md b/docs/gaps.md index c21d224ae..0508fde1d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -33,6 +33,10 @@ Active footnote and endnote text stays readable while editing (#2545). This fixe Russian language-aware sorting now keeps ё with е instead of after я, with the diaeresis as a secondary difference. Composed and decomposed ё and й compare equally. Fixed synthetic collation tests cover order, case and index headings; this does not establish full Cyrillic or CLDR collation parity. +Table sorting and formulas use decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), +matching their existing region settings. Parsing and result-formatting regression tests cover +both locales and preserve Spain's decimal comma; see [localization parity](localization-parity.md). + ## Ranked | # | Gap | B | Evidence | Impact | Hours | Doc | @@ -46,7 +50,7 @@ Russian language-aware sorting now keeps ё with е instead of after я, with th | 7 | **Dialog depth**: 59 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults) | 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** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), 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) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | -| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; 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) | +| 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**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [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) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | @@ -54,7 +58,7 @@ Russian language-aware sorting now keeps ё with е instead of after я, with th | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 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 import done, #1378; RTF export left; ODT export done), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~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) | +| 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 import done, #1378; RTF export left; ODT export done), 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) | | ~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) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF and ODT import and export done, #1378, #1379), RTL. 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`) | | ~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) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | 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 | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | @@ -111,8 +115,11 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| | 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged | +| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. | +| 2026-10-20 | trivial | Corrected Cuban and Peruvian Spanish table-number parsing and formula results to use decimal points; broader localization estimates unchanged | | 2026-10-21 | 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) | | 2026-10-21 | trivial | #3172: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are to a screen reader | +| 2026-10-21 | trivial | Gap 19: ODT table column widths are read on import, so they survive a save | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | @@ -140,6 +147,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-15 | trivial | Numbered headings (#1793): heading-linked Multilevel List presets; numbering keeps heading styles. Not a ranked gap; style gallery previews without numbers remain | | 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. | | 2026-10-16 | trivial | #17: automatic space between Asian text and Latin text or digits laid out (#2358) | +| 2026-10-16 | trivial | Drawing dispatch uses the direct graphic-data child so nested pictures keep their text boxes. Synthetic regressions. | | 2026-10-14 | trivial | Gap 19: RTF export writes sections (ODT export landed too) | | 2026-10-14 | trivial | #19 narrowed: ODT export done (master page and page layout per section, columned `text:section`s) | | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. | @@ -155,6 +163,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position | | 2026-10-16 | trivial | #8: English is three proofing languages — American, British spelling (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…) and Canadian — each flagging the other's spellings (#1281, #1045) | +| 2026-10-16 | trivial | #10 narrowed: pictures and shapes without text stories preview rotation while dragging (#2277); remaining object gaps and estimates unchanged | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 538ea6e19..6550842f9 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -59,7 +59,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | -| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | +| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell | — | @@ -219,6 +219,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | | 2026-10-17 | trivial | A float above its anchor whose wrap pushes the anchor onto the next page settles with no text under it on any page (#2048 review) | | 2026-10-15 | trivial | A float placed above its anchor paragraph (fixed on the page, or moved up from it) has the text of the paragraphs above the anchor on its page wrap around it, as in Word, instead of running behind it (#2048) | +| 2026-10-20 | trivial | A picture cropped to a sliver of its source (under 5% of an axis) is drawn at the size the file gives, on screen and in PDF, bitmap or metafile, and Tight/Through wrapping follows the part drawn; it was widened to 5%, so a header bar that is 2.7% of a page-sized WMF came out half as tall and the text box over it fell below it (#1899) | | 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) | | 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. | @@ -288,6 +289,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | | 2026-10-14 | trivial | A paragraph continued into the next column or page is broken again around the shapes there, not the ones where it started (#1374) | +| 2026-10-14 | trivial | A table with its own preferred width keeps it past the margin instead of being squeezed to the margins when over 1.5× the text width (#1721) | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) | | 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ff40202b2..498fe2bad 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -148,8 +148,10 @@ Romanian, Estonian, Latvian, Lithuanian, Slovenian and Icelandic (`crates/doc/sr a hand-written per-language alphabet table; full CLDR collation is not used). Table sort and table formulas read numbers with the language's decimal and group separators (`1 250,50` in Polish, `1.250,50` in German, `1,250.50` in English) and write formula results with its decimal -separator (`crates/doc/src/locale_number.rs`). Interface lists (styles, fonts) still sort by code -point. +separator (`crates/doc/src/locale_number.rs`). Cuban and Peruvian Spanish (`es-CU`, `es-PE`) use +decimal points (`1,234.50`), matching the existing region settings and CLDR's Latin American +Spanish parent locale; `cuban_and_peruvian_numbers_use_decimal_points` covers parsing and result +formatting. Interface lists (styles, fonts) still sort by code point. Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html). @@ -203,6 +205,7 @@ WordCraft keeps 2,54 cm). | 2026-10-21 | trivial | Japanese row: ruby (Phonetic Guide) is supported over its base text and HTML export/import keeps it (#1681); the row had listed ruby as missing | | 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged | | 2026-10-20 | trivial | File › Home's greeting (Good morning / afternoon / evening) added to the nine catalogs that lacked it (de, et, ja, pt-br, sr, sr-latn, uk, zh-hans, zh-hant); a test keeps it in every language | +| 2026-10-20 | trivial | Cuban and Peruvian Spanish table numbers use decimal points for parsing and formula results, matching CLDR and the existing region settings; regression covers grouped values and a Spain control | | 2026-10-14 | trivial | The sentence-capital rule (English, Polish, Croatian, Italian, Turkish, Portuguese) skips the first word of a list item (#1741) | | 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged | | 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3f84355b7..7713bedc4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -60,7 +60,7 @@ full parity for the area. | Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288), Enclose Characters (#297). Kerning for fonts in the Font dialog. Missing: Asian Typography, ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | -| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | +| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 24 deep at most, as the .docx reader and writer keep them (#1596; Word has no limit). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | @@ -263,6 +263,7 @@ can be used without committing Word output. | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-13 | trivial | Windows Magnifier follows the caret onto a new line (#1119): Accessibility row updated; no percentage change | +| 2026-10-14 | trivial | Tables: nesting stops at 24 levels, the depth .docx keeps (#1596); Tables row updated, no percentage change | | 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | | 2026-10-12 | trivial | The 62 open issues and ~45 open PRs cited as evidence are dated 2026-10-10 (the measurement pass); not re-counted (197 issues were open on 2026-10-12) | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62cea31c3..b5d44749a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -37,7 +37,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — | -| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | +| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | @@ -79,6 +79,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-20 | trivial | Tables on the page: the move and resize handles show while the caret is in the table, not only under the pointer (#2428) | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | +| 2026-10-18 | trivial | Tables: a single selected cell's border drags in its row only (Select Cell, left-edge arrow, triple-click); checked against Word for Mac 16.113.4 | | 2026-10-16 | trivial | Dragging the vertical scrollbar shows a tip beside the pointer with the page at the top of the window ("Page 7 of 10"); nothing said where the drag would land (#2551) | | 2026-10-17 | trivial | Recipient List dialog: Find in list box with a match count, "Show only records that will be merged", striped rows; hidden rows keep their record for edit and remove (#1576) | | 2026-10-18 | trivial | Picture Format › Size (#2573): the width and height show the interface unit and take a size typed in any unit, pixels too (`300 px`), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) with the proportions kept; the group's launcher opens a Size dialog (height and width, scale, Lock aspect ratio, Relative to original picture size, original size with its pixels, Reset); `picture.size` takes `original`; readiness estimates unchanged | @@ -124,6 +125,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged | | 2026-10-16 | trivial | Right-clicking a picture, shape, text box or chart offers Insert Caption…; a picture in a table gets a Figure caption in its cell. Page Setup has Headers and footers check boxes (Different Odd & Even Pages, Different First Page), applied on OK only if changed (#1230) | | 2026-10-17 | trivial | Context menu: Paste, Keep Text Only, Merge Formatting and Paste Special… replace the single Paste, the same items as Home › Paste's menu (#2583). Word's context menu shows its Paste Options as a row of icons; here they are labelled items with their shortcuts, as on the ribbon | +| 2026-10-16 | trivial | Rotation handle: pictures and shapes without text stories turn with the preview while the pointer is held; text boxes retain frame-only previews; release commits one `arrange.rotation` command, Escape cancels (#2277) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-17 | minor | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…) with a Drop Cap dialog (position, font, lines 1–10, distance 0–22 in), #2601; Modal dialogs 56 → 57 | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |