Compare commits
No commits in common. "main" and "codex/craft-print-onboarding" have entirely different histories.
main
...
codex/craf
|
|
@ -5098,7 +5098,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
|
|
@ -5130,7 +5130,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
|
|
@ -5142,11 +5142,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -5158,7 +5158,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
|
|
@ -5173,7 +5173,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
|
|
@ -5195,7 +5195,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
|
|
@ -5221,7 +5221,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
|
|
@ -5234,7 +5234,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
|
|
@ -5247,14 +5247,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
|
|
@ -5273,7 +5273,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
|
|
@ -5285,7 +5285,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -5293,7 +5293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
|
|
@ -5313,7 +5313,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
|
|
@ -5322,7 +5322,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
|
|
@ -5336,7 +5336,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
|
|
@ -5361,7 +5361,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5380,7 +5380,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -5493,7 +5493,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
29
ROADMAP.md
29
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously trivial: independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ agent control. This page is the summary; the assessment is
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **485 / 500 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
|
|
@ -135,7 +135,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
|
||||
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
|
||||
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
|
||||
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
|
||||
|
|
@ -167,21 +166,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
|
||||
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
|
||||
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
|
||||
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
|
||||
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
|
||||
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
|
||||
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
|
||||
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
|
||||
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
|
||||
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
|
||||
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
|
||||
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
|
||||
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
|
||||
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
|
||||
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
|
||||
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
|
||||
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
|
||||
|
|
@ -222,7 +209,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
|
||||
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
|
||||
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
|
||||
| 2026-10-12 | Selection bar (#856): in the left margin beside body text a click selects the line, a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; dragging there selects whole lines and Shift+click extends by lines; the pointer turns to an arrow. Right margin for right-to-left paragraphs not yet |
|
||||
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
|
||||
| 2026-10-11 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
|
||||
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
|
||||
|
|
@ -273,9 +259,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
|
||||
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
|
||||
| 2026-10-11 | trivial | Progress log: the table context menu |
|
||||
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
|
||||
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
|
||||
|
|
@ -283,12 +266,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
|
||||
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
|
||||
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
|
||||
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
|
||||
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
|
||||
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
|
||||
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
|
||||
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
|
||||
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
|
||||
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
|
||||
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
|
||||
|
|
@ -302,7 +279,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
|
||||
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
|
||||
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
|
|
@ -316,7 +292,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
|
||||
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
|
||||
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
|
||||
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
|
||||
| 2026-10-11 | trivial | Progress log: content controls (#381) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
|
||||
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use wordcraft_engine::Session;
|
|||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
mod font_store;
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
|
|
|
|||
|
|
@ -64,11 +64,9 @@ sync_github_name = false
|
|||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
[people.edurbs]
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
[people.manucarcor]
|
||||
real_name = "Manuel Carmona"
|
||||
display_name = "manucarcor"
|
||||
sync_github_name = false
|
||||
|
||||
[people.odedigo]
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ impl Fragment {
|
|||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.plain_text(crate::StoryRef::Body)
|
||||
}
|
||||
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
|
||||
/// (Wingdings…) as their Unicode equivalents.
|
||||
pub fn clipboard_text(&self) -> String {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.shown_text(crate::StoryRef::Body)
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
Fragment {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
|
|
@ -16,176 +14,3 @@ pub fn cp1252(b: u8) -> char {
|
|||
b as char
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP874: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
|
||||
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
|
||||
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
|
||||
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
|
||||
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
|
||||
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
|
||||
0xFFFD, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
|
||||
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
|
||||
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
|
||||
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
|
||||
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
|
||||
0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1251: [u16; 128] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
|
||||
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
|
||||
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
|
||||
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
|
||||
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
|
||||
0x044E, 0x044F,
|
||||
];
|
||||
|
||||
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1253: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
|
||||
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
|
||||
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
|
||||
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
|
||||
0x03CE, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1254: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
|
||||
0x015F, 0x00FF,
|
||||
];
|
||||
|
||||
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1255: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1256: [u16; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1257: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
|
||||
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
|
||||
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
|
||||
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
|
||||
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
|
||||
0x017E, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1258: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
|
||||
0x20AB, 0x00FF,
|
||||
];
|
||||
|
||||
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
|
||||
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
|
||||
|
||||
/// One byte of a single-byte Windows code page as a char; a code page outside
|
||||
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
|
||||
pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
874 => &CP874,
|
||||
1250 => &CP1250,
|
||||
1251 => &CP1251,
|
||||
1253 => &CP1253,
|
||||
1254 => &CP1254,
|
||||
1255 => &CP1255,
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
return b as char;
|
||||
}
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
|
||||
/// symbol fonts and the double-byte East Asian charsets.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
177 => 1255,
|
||||
178 => 1256,
|
||||
186 => 1257,
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_byte_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
|
||||
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
|
||||
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
|
||||
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
|
||||
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
|
||||
assert_eq!(decode(9999, &[0xE9]), "é");
|
||||
assert_eq!(charset_code_page(204), Some(1251));
|
||||
assert_eq!(charset_code_page(0), None);
|
||||
for cp in SINGLE_BYTE_CODE_PAGES {
|
||||
for b in 0..=255u8 {
|
||||
let _ = windows_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ pub mod resolve;
|
|||
pub mod section;
|
||||
pub mod smart_art;
|
||||
pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod wrap;
|
||||
|
|
@ -802,19 +801,9 @@ impl Document {
|
|||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, false)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
|
||||
/// Unicode equivalents ([`Paragraph::shown_text`]).
|
||||
pub fn shown_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, true)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, shown: bool) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0, shown);
|
||||
text_of(bl, &mut out, 0);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
|
|
@ -892,22 +881,14 @@ impl Document {
|
|||
id
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
/// including generated numbered-list labels).
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit).
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.story_word_count(StoryRef::Body)
|
||||
count_words(&self.plain_text(StoryRef::Body))
|
||||
}
|
||||
|
||||
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
|
||||
pub fn word_count_including_notes(&self) -> usize {
|
||||
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
|
||||
}
|
||||
|
||||
/// Word count of one story, including generated numbered-list labels.
|
||||
pub fn story_word_count(&self, s: StoryRef) -> usize {
|
||||
let Some(bl) = self.story(s) else { return 0 };
|
||||
let mut counters = numbering::Counters::default();
|
||||
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
|
||||
self.counted_stories().iter().map(|s| count_words(&self.plain_text(*s))).sum()
|
||||
}
|
||||
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
|
|
@ -1295,14 +1276,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&if shown { p.shown_text() } else { p.plain_text() });
|
||||
out.push_str(&p.plain_text());
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
|
|
@ -1310,7 +1291,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
|||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, shown);
|
||||
text_of(&c.blocks, &mut s, depth + 1);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
|
|
@ -1321,34 +1302,5 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize {
|
||||
if depth > 16 {
|
||||
return 0;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
if let Some(n) = n
|
||||
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
|
||||
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
|
||||
{
|
||||
total = total.saturating_add(count_words(&label));
|
||||
}
|
||||
total = total.saturating_add(count_words(&p.plain_text()));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
for c in &row.cells {
|
||||
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -831,30 +831,6 @@ impl Paragraph {
|
|||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
|
||||
/// (Symbol, Wingdings…) become their standard Unicode equivalents
|
||||
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
|
||||
/// unchanged.
|
||||
pub fn shown_text(&self) -> String {
|
||||
let mut out = String::with_capacity(self.text.len());
|
||||
let mut k = 0;
|
||||
let mut runs = self.run_ranges().peekable();
|
||||
for (i, c) in self.text.char_indices() {
|
||||
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
|
||||
runs.next();
|
||||
}
|
||||
if c == OBJ {
|
||||
if let Some(o) = self.objects.get(k) {
|
||||
out.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
|
||||
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
|
||||
pub fn final_text(&self) -> String {
|
||||
self.text_without(&self.deleted_ranges())
|
||||
|
|
@ -1064,12 +1040,7 @@ impl Paragraph {
|
|||
if self.runs.is_empty() || a == 0 {
|
||||
// Keep the formatting of what's deleted for the mark when emptying.
|
||||
if b == self.text.len() {
|
||||
// Formatting only: the mark is a tracked change of its own or it is not, whatever
|
||||
// revision the deleted text was part of.
|
||||
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
|
||||
let mut mark = self.props_of_char(a).clone();
|
||||
(mark.ins, mark.del, mark.fmt_change) = revisions;
|
||||
self.mark = mark;
|
||||
self.mark = self.props_of_char(a).clone();
|
||||
}
|
||||
}
|
||||
let k0 = self.objects_before(a);
|
||||
|
|
@ -1441,24 +1412,6 @@ mod tests {
|
|||
assert_eq!(t.mark.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
|
||||
let mut props = bold();
|
||||
props.ins = Some(3);
|
||||
props.del = Some(4);
|
||||
let mut p = Paragraph::with_text("Hello", props);
|
||||
p.mark = bold();
|
||||
p.delete(0, 5).unwrap();
|
||||
assert!(p.is_empty());
|
||||
assert_eq!(p.mark.bold, Some(true));
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, None));
|
||||
// A mark that is itself a tracked change keeps its own revision.
|
||||
let mut p = Paragraph::with_text("Hello", bold());
|
||||
p.mark.del = Some(7);
|
||||
p.delete(0, 5).unwrap();
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words() {
|
||||
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
|
||||
|
|
|
|||
|
|
@ -31,42 +31,13 @@ impl Rgb {
|
|||
}
|
||||
|
||||
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum TextColor {
|
||||
#[default]
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
|
||||
impl TextColor {
|
||||
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
|
||||
pub fn parse(s: &str) -> Option<TextColor> {
|
||||
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TextColor {
|
||||
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
|
||||
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, Unexpected};
|
||||
#[derive(Deserialize)]
|
||||
enum Written {
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::String(s) => {
|
||||
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
|
||||
}
|
||||
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
|
||||
Written::Auto => TextColor::Auto,
|
||||
Written::Rgb(c) => TextColor::Rgb(c),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Underline {
|
||||
|
|
@ -1154,27 +1125,6 @@ mod tests {
|
|||
assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
|
||||
}
|
||||
|
||||
/// #865: a colour reads as `format.color` takes it (`RRGGBB`, `#RRGGBB`, `auto`), while what
|
||||
/// documents and settings already hold reads and writes exactly as before.
|
||||
#[test]
|
||||
fn text_color_reads_hex_and_keeps_its_serialized_forms() {
|
||||
let read = |j: &str| serde_json::from_str::<TextColor>(j);
|
||||
let ink = TextColor::Rgb(Rgb(0x1B, 0x16, 0x11));
|
||||
assert_eq!(read(r#""1B1611""#).unwrap(), ink);
|
||||
assert_eq!(read(r##""#1b1611""##).unwrap(), ink);
|
||||
assert_eq!(read(r#""auto""#).unwrap(), TextColor::Auto);
|
||||
assert_eq!(read(r#""Automatic""#).unwrap(), TextColor::Auto);
|
||||
for (c, j) in [(TextColor::Auto, r#""Auto""#), (ink, r#"{"Rgb":[27,22,17]}"#)] {
|
||||
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
|
||||
assert_eq!(read(j).unwrap(), c);
|
||||
}
|
||||
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B1611"}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
|
||||
assert!(read(bad).is_err(), "{bad}");
|
||||
}
|
||||
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
|
||||
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn props_serde_skip_none() {
|
||||
let p = CharProps { bold: Some(true), ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to
|
||||
//! standard Unicode characters, so text set in them shows the intended symbols when the font
|
||||
//! isn't installed (they are proprietary and never bundled) and exports as real characters.
|
||||
//!
|
||||
//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own
|
||||
//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code
|
||||
//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text
|
||||
//! never changes: the mapping is for display, layout and plain-text output only.
|
||||
//!
|
||||
//! Sources (Unicode's own published data; no font file was read):
|
||||
//! - Symbol: the Adobe Symbol encoding table published by Unicode,
|
||||
//! <https://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt> (codes it maps to the
|
||||
//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU).
|
||||
//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of
|
||||
//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard,
|
||||
//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052,
|
||||
//! L2/11-344), checked against the Unicode Character Database names. The one character not
|
||||
//! encoded (Wingdings 0xFF) has no entry.
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") {
|
||||
Some(&SYMBOL)
|
||||
} else if f.eq_ignore_ascii_case("wingdings") {
|
||||
Some(&WINGDINGS)
|
||||
} else if f.eq_ignore_ascii_case("wingdings 2") {
|
||||
Some(&WINGDINGS_2)
|
||||
} else if f.eq_ignore_ascii_case("wingdings 3") {
|
||||
Some(&WINGDINGS_3)
|
||||
} else if f.eq_ignore_ascii_case("webdings") {
|
||||
Some(&WEBDINGS)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
table(family).is_some()
|
||||
}
|
||||
|
||||
/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the
|
||||
/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD).
|
||||
/// `None` for other fonts, other characters and codes with no Unicode equivalent.
|
||||
pub fn symbol_char(family: &str, c: char) -> Option<char> {
|
||||
let t = table(family)?;
|
||||
let u = u32::from(c);
|
||||
let code = match u {
|
||||
0xF020..=0xF0FF => u - 0xF000,
|
||||
// A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD).
|
||||
0xAD => return None,
|
||||
0x20..=0xFF => u,
|
||||
_ => return None,
|
||||
};
|
||||
let i = usize::try_from(code.checked_sub(0x20)?).ok()?;
|
||||
t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32)
|
||||
}
|
||||
|
||||
/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode
|
||||
/// equivalent; unchanged for other fonts.
|
||||
pub fn map_text(family: &str, text: &str) -> String {
|
||||
if !is_symbol_font(family) {
|
||||
return text.to_string();
|
||||
}
|
||||
text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect()
|
||||
}
|
||||
|
||||
const SYMBOL: [u32; 224] = [
|
||||
0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20
|
||||
0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30
|
||||
0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38
|
||||
0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40
|
||||
0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48
|
||||
0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50
|
||||
0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58
|
||||
0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60
|
||||
0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68
|
||||
0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70
|
||||
0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98
|
||||
0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0
|
||||
0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8
|
||||
0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0
|
||||
0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8
|
||||
0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0
|
||||
0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8
|
||||
0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0
|
||||
0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8
|
||||
0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8
|
||||
0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WEBDINGS: [u32; 224] = [
|
||||
0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20
|
||||
0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28
|
||||
0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30
|
||||
0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38
|
||||
0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40
|
||||
0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48
|
||||
0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50
|
||||
0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58
|
||||
0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60
|
||||
0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68
|
||||
0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70
|
||||
0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78
|
||||
0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80
|
||||
0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88
|
||||
0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90
|
||||
0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98
|
||||
0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0
|
||||
0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8
|
||||
0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0
|
||||
0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8
|
||||
0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0
|
||||
0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8
|
||||
0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0
|
||||
0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8
|
||||
0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0
|
||||
0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8
|
||||
0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0
|
||||
0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS: [u32; 224] = [
|
||||
0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20
|
||||
0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28
|
||||
0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30
|
||||
0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38
|
||||
0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40
|
||||
0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48
|
||||
0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50
|
||||
0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58
|
||||
0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60
|
||||
0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68
|
||||
0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70
|
||||
0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78
|
||||
0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80
|
||||
0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88
|
||||
0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90
|
||||
0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98
|
||||
0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0
|
||||
0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8
|
||||
0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0
|
||||
0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8
|
||||
0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0
|
||||
0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8
|
||||
0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0
|
||||
0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8
|
||||
0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0
|
||||
0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8
|
||||
0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0
|
||||
0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS_2: [u32; 224] = [
|
||||
0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20
|
||||
0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28
|
||||
0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30
|
||||
0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38
|
||||
0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40
|
||||
0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48
|
||||
0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50
|
||||
0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58
|
||||
0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60
|
||||
0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68
|
||||
0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70
|
||||
0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78
|
||||
0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80
|
||||
0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88
|
||||
0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90
|
||||
0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98
|
||||
0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0
|
||||
0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8
|
||||
0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0
|
||||
0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8
|
||||
0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0
|
||||
0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8
|
||||
0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0
|
||||
0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8
|
||||
0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0
|
||||
0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8
|
||||
0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0
|
||||
0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS_3: [u32; 224] = [
|
||||
0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20
|
||||
0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28
|
||||
0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30
|
||||
0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38
|
||||
0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40
|
||||
0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48
|
||||
0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50
|
||||
0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58
|
||||
0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60
|
||||
0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68
|
||||
0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70
|
||||
0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78
|
||||
0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80
|
||||
0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88
|
||||
0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90
|
||||
0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98
|
||||
0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0
|
||||
0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8
|
||||
0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0
|
||||
0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8
|
||||
0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0
|
||||
0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8
|
||||
0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0
|
||||
0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8
|
||||
0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0
|
||||
0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8
|
||||
0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_storage_forms_map_alike() {
|
||||
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
|
||||
assert!(is_symbol_font(f));
|
||||
for code in 0x20u32..=0xFF {
|
||||
let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue };
|
||||
if code == 0xAD {
|
||||
assert_eq!(symbol_char(f, plain), None);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_uses_the_adobe_symbol_encoding() {
|
||||
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
|
||||
assert_eq!(symbol_char("symbol", 'a'), Some('α'));
|
||||
assert_eq!(symbol_char("Symbol", 'm'), Some('μ'));
|
||||
assert_eq!(symbol_char("Symbol", 'W'), Some('Ω'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√'));
|
||||
// The radical extender is private use in the Adobe table: no equivalent.
|
||||
assert_eq!(symbol_char("Symbol", '\u{F060}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_codes() {
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺'));
|
||||
assert_eq!(symbol_char("Wingdings", 'J'), Some('☺'));
|
||||
assert_eq!(symbol_char("Wingdings", 'L'), Some('☹'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}'));
|
||||
// The one character Unicode didn't encode.
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_2_3_and_webdings_codes() {
|
||||
assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}'));
|
||||
assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}'));
|
||||
assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None);
|
||||
assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠'));
|
||||
assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None);
|
||||
assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}'));
|
||||
assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}'));
|
||||
assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_fonts_and_characters_are_left_alone() {
|
||||
assert!(!is_symbol_font("Arial"));
|
||||
assert_eq!(symbol_char("Arial", '\u{F04A}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{1F}'), None);
|
||||
assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}");
|
||||
assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,51 +16,6 @@ fn word_count() {
|
|||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let bul_id = d.numbering.add_list(ListKind::Bullet);
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
let mut p2 = Paragraph::with_text("Banana", CharProps::default());
|
||||
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
|
||||
let mut p3 = Paragraph::with_text("Cherry", CharProps::default());
|
||||
p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
|
||||
let mut p4 = Paragraph::with_text("Date", CharProps::default());
|
||||
p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
|
||||
|
||||
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))];
|
||||
|
||||
// Numbered items contribute 2 words each (label + text) = 4 words.
|
||||
// Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words.
|
||||
assert_eq!(d.word_count(), 6);
|
||||
let plain = d.plain_text(StoryRef::Body);
|
||||
assert_eq!(plain, "Apple\nBanana\nCherry\nDate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default());
|
||||
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))];
|
||||
|
||||
// Label "1." and text "Apple" must each be counted as a separate word (2 words),
|
||||
// and label "2." and text "Banana Cherry" as 3 words (total: 5 words),
|
||||
// even though LevelSuffix::Nothing produces no visible separator between label and text.
|
||||
assert_eq!(d.word_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_and_section_mut() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
|
|
|
|||
|
|
@ -178,31 +178,6 @@ fn hyperlink_runs_and_wrappers() {
|
|||
assert_eq!(p[1].plain_text(), "in block sdt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() {
|
||||
// Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a
|
||||
// run whose font is Wingdings; plus Symbol and Webdings.
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:t xml:space="preserve">Done </w:t></w:r>
|
||||
<w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr><w:t>J</w:t></w:r>
|
||||
<w:r><w:sym w:font="Symbol" w:char="F061"/><w:sym w:font="Webdings" w:char="F061"/></w:r>
|
||||
</w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = paras(&d);
|
||||
// Stored as Word stores it…
|
||||
assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}");
|
||||
// …read (copied, exported as text) as the symbols shown.
|
||||
assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
// A round trip keeps the original characters and fonts.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let q = paras(&again);
|
||||
assert_eq!(q[0].plain_text(), p[0].plain_text());
|
||||
assert_eq!(q[0].shown_text(), p[0].shown_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_content_choice_and_fallback() {
|
||||
let body = r#"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ Quick Access|Toolbar|Undo List|edit.undoTo
|
|||
Quick Access|Toolbar|Redo|edit.redo
|
||||
Quick Access|Toolbar|Repeat|edit.repeat
|
||||
Quick Access|Toolbar|AutoSave|file.autosave
|
||||
Title Bar|Search|Search (Tell Me)|tools.search
|
||||
Home|Clipboard|Paste|edit.paste
|
||||
Home|Clipboard|Paste Keep Text Only|edit.pasteText
|
||||
Home|Clipboard|Paste Merge Formatting|edit.pasteMerge
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
|
||||
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
|
||||
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
|
||||
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.params(r#"{"unit": "word|sentence|paragraph", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
|
||||
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
|
||||
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
|
||||
let to_end = p::bool(v, "end").unwrap_or(false);
|
||||
|
|
@ -291,15 +291,12 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
|
||||
/// selection bar (the left margin) selects a line; triple-click there the whole document.
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unit {
|
||||
Word,
|
||||
Sentence,
|
||||
Line,
|
||||
Paragraph,
|
||||
Document,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
|
|
@ -307,36 +304,26 @@ impl Unit {
|
|||
match s {
|
||||
"word" => Some(Unit::Word),
|
||||
"sentence" => Some(Unit::Sentence),
|
||||
"line" => Some(Unit::Line),
|
||||
"paragraph" => Some(Unit::Paragraph),
|
||||
"document" => Some(Unit::Document),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The word, sentence, line, paragraph or story around `at`, as (start, end). A paragraph
|
||||
/// includes its mark (it ends at the start of the next paragraph in the same container, when
|
||||
/// there is one), and so does its last line.
|
||||
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
/// The word, sentence or paragraph around `at`, as (start, end). A paragraph includes its mark
|
||||
/// (it ends at the start of the next paragraph in the same container, when there is one).
|
||||
pub fn unit_range(s: &Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
|
||||
let len = p.len();
|
||||
let mark_end = |s: &Session| match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: len, ..at.clone() },
|
||||
};
|
||||
let (a, b) = match unit {
|
||||
Unit::Word => p.word_at(at.off),
|
||||
Unit::Sentence => p.sentence_at(at.off),
|
||||
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
|
||||
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
|
||||
Unit::Line => {
|
||||
let hint = s.page_hint;
|
||||
return match s.layout().line_span(at, hint) {
|
||||
Some((a, _, true)) => (a, mark_end(s)),
|
||||
Some((a, b, false)) => (a, b),
|
||||
None => (at.clone(), at.clone()),
|
||||
Unit::Paragraph => {
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: p.len(), ..at.clone() },
|
||||
};
|
||||
return (start, end);
|
||||
}
|
||||
};
|
||||
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
|
||||
|
|
@ -363,23 +350,12 @@ fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
|||
select_unit(s, Unit::Paragraph)
|
||||
}
|
||||
|
||||
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
|
||||
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
|
||||
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pos) = v.get("pos").and_then(parse_pos) {
|
||||
s.sel = Selection::caret(s.doc.clamp(&pos));
|
||||
}
|
||||
select_unit(s, Unit::Line)
|
||||
}
|
||||
|
||||
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
|
||||
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
|
||||
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
|
||||
/// anchors at its end, as in Word.
|
||||
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let unit = p::str(v, "unit")
|
||||
.and_then(Unit::parse)
|
||||
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".into()))?;
|
||||
let unit = p::str(v, "unit").and_then(Unit::parse).ok_or_else(|| CmdError::Params("`unit` must be word, sentence or paragraph".into()))?;
|
||||
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
|
||||
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
|
||||
let (start, end) = if start <= end { (start, end) } else { (end, start) };
|
||||
|
|
@ -388,18 +364,20 @@ fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
|||
return sel_result(s);
|
||||
}
|
||||
let (us, ue) = unit_range(s, &at, unit);
|
||||
s.sel = if unit == Unit::Document {
|
||||
Selection { anchor: us, focus: ue }
|
||||
} else if at >= start {
|
||||
Selection { focus: ue.max(end.clone()), anchor: start }
|
||||
} else {
|
||||
Selection { focus: us.min(start), anchor: end }
|
||||
};
|
||||
s.sel = if at >= start { Selection { focus: ue.max(end.clone()), anchor: start } } else { Selection { focus: us.min(start), anchor: end } };
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let l = s.layout();
|
||||
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
|
||||
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let needle = p::req_str(v, "text")?;
|
||||
|
|
|
|||
|
|
@ -114,25 +114,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
// Ctrl+H, Home › Replace: the Find and Replace dialog on its Replace tab (#712). Opening
|
||||
// it changes nothing, so it is its own command and never marks the document edited.
|
||||
CommandSpec::new("edit.findReplace", "Find and Replace", "Home › Editing", |s, v| {
|
||||
let tab = match p::str(v, "tab").unwrap_or("replace") {
|
||||
t @ ("find" | "replace" | "goto") => t,
|
||||
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
|
||||
};
|
||||
s.ui_requests.push(json!({"open": tab}));
|
||||
Ok(json!({"open": tab}))
|
||||
})
|
||||
.key("Mod+H")
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
|
||||
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
|
||||
|
|
@ -159,7 +146,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
|||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
|
|
@ -534,10 +521,8 @@ fn read_opts(s: &mut Session, v: &Value) {
|
|||
fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
read_opts(s, v);
|
||||
if p::str(v, "text").is_none() {
|
||||
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
|
||||
// Find and Replace dialog is Advanced Find.
|
||||
s.view.nav_pane = true;
|
||||
s.ui_requests.push(json!({"focus": "navSearch"}));
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
|
|
@ -648,13 +633,7 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Relative pages, Go To's "+2" / "-1": from the page the caret is on.
|
||||
let by = v.get("pageBy").and_then(Value::as_i64);
|
||||
let rel = by.map(|by| {
|
||||
let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64;
|
||||
(here + 1).saturating_add(by).max(1) as u64
|
||||
});
|
||||
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
|
||||
if let Some(pg) = p::u64(v, "page") {
|
||||
let l = s.layout();
|
||||
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
|
||||
let target = l.pages.get(idx).and_then(|page| {
|
||||
|
|
|
|||
|
|
@ -361,7 +361,11 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
|||
|
||||
fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "color")?;
|
||||
let tc = TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?;
|
||||
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
|
||||
TextColor::Auto
|
||||
} else {
|
||||
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
|
||||
};
|
||||
apply(s, &|x| x.color = Some(tc))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,26 +201,20 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
|||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
let (pw, ph) =
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Fitted to the text width.
|
||||
// Natural size at 96 ppi, fitted to the text width.
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
w *= k;
|
||||
|
|
|
|||
|
|
@ -480,24 +480,14 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
|||
"oddPage" => SectionStart::OddPage,
|
||||
x => return Err(CmdError::Params(format!("unknown break `{x}`"))),
|
||||
};
|
||||
// A section break goes just before the selection and never replaces it, as in Word (#852:
|
||||
// selected text was deleted, so the first section lost its text).
|
||||
let old = s.sel.clone();
|
||||
let (at, end) = old.ordered();
|
||||
if at.story != wordcraft_doc::StoryRef::Body || at.path.depth() > 0 {
|
||||
if s.sel.focus.story != wordcraft_doc::StoryRef::Body || s.sel.focus.path.depth() > 0 {
|
||||
return Err(CmdError::Failed("section breaks go in the main text".into()));
|
||||
}
|
||||
// Section break: the paragraph before the caret ends a section with the current props; the
|
||||
// new section (after) takes them too and starts as requested.
|
||||
s.sel = Selection::caret(at.clone());
|
||||
let at = super::delete_selection(s)?;
|
||||
let cur = sect(s);
|
||||
let new = match s.doc.split_paragraph(&at) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
s.sel = old;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let new = s.doc.split_paragraph(&at)?;
|
||||
let ended = at.path.clone();
|
||||
let para = s.doc.para_mut(at.story, &ended)?;
|
||||
let mut props = cur;
|
||||
|
|
@ -506,32 +496,11 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
|||
para.section = Some(Box::new(props.clone()));
|
||||
props.start = start;
|
||||
*s.doc.section_mut(next_block) = props;
|
||||
// The selection keeps its text, which now starts the new section.
|
||||
let first = Pos { off: 0, ..new };
|
||||
let last = shifted_by_split(&end, &at, &first);
|
||||
s.sel = if old.anchor <= old.focus { Selection { anchor: first, focus: last } } else { Selection { anchor: last, focus: first } };
|
||||
s.sel = Selection::caret(Pos { off: 0, ..new });
|
||||
let _ = Block::Para;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Where `p` lands after the top-level body paragraph at `at` was split there, the second half
|
||||
/// starting at `new`: later text in that paragraph moves into the new one, later blocks move down.
|
||||
fn shifted_by_split(p: &Pos, at: &Pos, new: &Pos) -> Pos {
|
||||
if p.story != at.story {
|
||||
return p.clone();
|
||||
}
|
||||
if p.path == at.path {
|
||||
return if p.off >= at.off { Pos { off: p.off.saturating_sub(at.off), ..new.clone() } } else { p.clone() };
|
||||
}
|
||||
let mut q = p.clone();
|
||||
if let (Some(first), Some(i)) = (q.path.0.first_mut(), at.path.0.first())
|
||||
&& *first > *i
|
||||
{
|
||||
*first = first.saturating_add(1);
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -563,56 +532,6 @@ mod tests {
|
|||
assert_eq!(sect(&s).text_direction, TextDirection::Down);
|
||||
}
|
||||
|
||||
/// #852: a continuous section break with text selected goes before the selection and keeps
|
||||
/// it, as in Word; it used to delete the text, so the first section came out empty. Saved and
|
||||
/// opened again, both sections' text lays out on page 1, the second below the first.
|
||||
#[test]
|
||||
fn section_break_keeps_the_selected_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "one");
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
let texts = |d: &wordcraft_doc::Document| -> Vec<String> {
|
||||
d.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().to_string()) } else { None }).collect()
|
||||
};
|
||||
assert_eq!(texts(&s.doc), ["", "one", "two"]);
|
||||
// A backward selection in the middle of a paragraph keeps its text and direction too.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("alpha beta\ngamma"));
|
||||
let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
|
||||
s.sel = Selection { anchor: p(10), focus: p(6) };
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "beta");
|
||||
assert!(s.sel.anchor > s.sel.focus);
|
||||
assert_eq!(texts(&s.doc), ["alpha ", "beta", "gamma"]);
|
||||
|
||||
// The issue's document: "one", a continuous break, "two" — both on page 1 after a round trip.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("caret.end", &json!({})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.sections().len(), 2);
|
||||
assert_eq!(back.last_section.start, SectionStart::Continuous);
|
||||
let mut s = Session::new(back);
|
||||
let l = s.layout();
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let mut lines: Vec<(String, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
crate::layout::Placed::Lines { path, y, .. } => {
|
||||
let Some(Block::Para(p)) = s.doc.body.get(path.last()).map(|b| &**b) else { return None };
|
||||
(!p.plain_text().is_empty()).then(|| (p.plain_text().to_string(), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let order: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(order, ["one", "two"]);
|
||||
}
|
||||
|
||||
/// More Columns: unequal widths with a line between, as one undo step.
|
||||
#[test]
|
||||
fn unequal_columns_with_a_line_between() {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
|||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None,
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
|
|||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||||
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
|
||||
if let Some(f) = &s.clipboard
|
||||
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
|
||||
&& text.as_ref().is_none_or(|t| f.plain_text() == *t)
|
||||
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
|
||||
{
|
||||
return Ok(f.clone());
|
||||
|
|
|
|||
|
|
@ -753,11 +753,7 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
|||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || super::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let words = wordcraft_doc::count_words(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
//! Resizing a table with the mouse, like Word: drag a border between columns (or the table's
|
||||
//! left or right edge), a row's bottom border, or the small handle just outside the table's
|
||||
//! bottom-right corner to scale the whole table. With cells selected, a border of theirs moves
|
||||
//! only in their rows. The geometry comes from the layout (what is on the page, in
|
||||
//! right edge), a row's bottom border, or the small handle just outside the table's bottom-right
|
||||
//! corner to scale the whole table. The geometry comes from the layout (what is on the page, in
|
||||
//! page points), so the result is what the drag showed even when autofit or a percentage width
|
||||
//! made the drawn columns differ from the stored grid. The app sends each finished drag here,
|
||||
//! so every drag is one undo step.
|
||||
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::props::{Align, HeightRule, VMerge};
|
||||
use wordcraft_doc::table::MAX_COLS;
|
||||
use wordcraft_doc::{Cell, Document, Path, StoryRef, Table};
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
use wordcraft_doc::{Document, Path, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
|
|
@ -29,15 +27,15 @@ const MAX_LEN: f32 = 1584.0;
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec::new("table.resizeDrag", "Resize Table by Dragging", "Table Layout › Cell Size", resize_drag).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "shift"?: bool, "ctrl"?: bool, "reach"?: pt} (a mouse drag in page points, 0-based page, starting on a table border or the resize handle: a column border moves (the column to its right gives or takes the difference, so the table keeps its width; with ctrl the columns right of it share it in proportion; with shift, or on the table's right edge, only the column left of it changes); the table's left edge moves a left-aligned table's indent, the first column making up the difference (a centred or right-aligned table keeps its alignment: only the first column changes); with cells of the table selected, a border of theirs moves only in their rows; a row's bottom border sets that row's height; the handle just outside the bottom-right corner scales every column and the rows on that page. `reach` (default 3) is how near the start has to be.)"#,
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "shift"?: bool, "reach"?: pt} (a mouse drag in page points, 0-based page, starting on a table border or the resize handle: a column border moves (the column to its right gives or takes the difference, so the table keeps its width; with shift, or on the table's right edge, only the column left of it changes); a row's bottom border sets that row's height; the handle just outside the bottom-right corner scales every column and the rows on that page. `reach` (default 3) is how near the start has to be.)"#,
|
||||
)]
|
||||
}
|
||||
|
||||
/// What a press on the page would grab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
/// The vertical border at grid line `line` (0 = the table's left edge, 1 = between the first
|
||||
/// two columns; the last is the table's right edge).
|
||||
/// The vertical border at grid line `line` (1 = between the first two columns; the last is
|
||||
/// the table's right edge).
|
||||
Column(usize),
|
||||
/// The bottom border of row `row`.
|
||||
Row(usize),
|
||||
|
|
@ -45,27 +43,6 @@ pub enum Edge {
|
|||
Corner,
|
||||
}
|
||||
|
||||
/// Who makes up the difference when a column border moves: Word's keys while dragging.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Share {
|
||||
/// No key: the column on the right, so the table keeps its width.
|
||||
#[default]
|
||||
Neighbour,
|
||||
/// Shift (and a ruler marker): nobody; only the column on the left changes and the ones
|
||||
/// right of it move along.
|
||||
LeftOnly,
|
||||
/// Ctrl: the columns on the right, in proportion to their widths, so the table keeps its
|
||||
/// width.
|
||||
Proportional,
|
||||
}
|
||||
|
||||
/// Whether Shift is held.
|
||||
impl From<bool> for Share {
|
||||
fn from(shift: bool) -> Share {
|
||||
if shift { Share::LeftOnly } else { Share::Neighbour }
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the table as drawn on one page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawnRow {
|
||||
|
|
@ -88,10 +65,6 @@ pub struct ResizeTarget {
|
|||
pub rows: Vec<DrawnRow>,
|
||||
/// Whether the table's last row is on this page (the handle is shown only then).
|
||||
pub ends_here: bool,
|
||||
/// When cells of the table are selected and the column border is one of theirs
|
||||
/// (`limit_to_selection`): their rows, each with the grid lines its cells' borders are on.
|
||||
/// The border moves only there; empty, it moves in the whole column.
|
||||
pub only: Vec<(usize, Vec<usize>)>,
|
||||
}
|
||||
|
||||
/// What a drag to a point would make of the table.
|
||||
|
|
@ -101,13 +74,6 @@ pub struct Plan {
|
|||
pub widths: Option<Vec<f32>>,
|
||||
/// New heights (points) of these rows.
|
||||
pub heights: Vec<(usize, f32)>,
|
||||
/// How far (points) the table's left edge moves: a left-aligned table's indent (or a
|
||||
/// floating table's offset) changes by as much; a centred or right-aligned table keeps its
|
||||
/// alignment.
|
||||
pub indent_by: Option<f32>,
|
||||
/// New cell widths (points, left to right) of the rows whose border moves on its own
|
||||
/// (`ResizeTarget::only`).
|
||||
pub cells: Vec<(usize, Vec<f32>)>,
|
||||
}
|
||||
|
||||
impl ResizeTarget {
|
||||
|
|
@ -132,52 +98,35 @@ impl ResizeTarget {
|
|||
Rect::new(self.right() + 1.0, self.bottom() + 1.0, HANDLE, HANDLE)
|
||||
}
|
||||
|
||||
/// The new widths and heights a drag that moved the pointer by (`dx`, `dy`) points gives;
|
||||
/// `share` says who makes up a column border's move (`true`: Shift is held).
|
||||
pub fn plan(&self, dx: f32, dy: f32, share: impl Into<Share>) -> Plan {
|
||||
let share = share.into();
|
||||
/// The new widths and heights a drag that moved the pointer by (`dx`, `dy`) points gives.
|
||||
pub fn plan(&self, dx: f32, dy: f32, shift: bool) -> Plan {
|
||||
let (dx, dy) =
|
||||
(if dx.is_finite() { dx.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }, if dy.is_finite() { dy.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 });
|
||||
let mut w = self.widths();
|
||||
match self.edge {
|
||||
// The table's left edge: the table moves in or out and the first column makes up the
|
||||
// difference, so the other borders stay.
|
||||
Edge::Column(0) => {
|
||||
let Some(first) = w.first_mut() else { return Plan::default() };
|
||||
let d = dx.clamp(*first - MAX_LEN, (*first - MIN_COL).max(*first - MAX_LEN));
|
||||
*first -= d;
|
||||
Plan { widths: Some(w), indent_by: Some(d), ..Plan::default() }
|
||||
Edge::Column(line) => {
|
||||
let Some(left) = line.checked_sub(1).and_then(|i| w.get(i).copied()) else { return Plan::default() };
|
||||
let right = w.get(line).copied();
|
||||
let (new_left, new_right) = match right {
|
||||
// An inner border: the neighbour on the right makes up the difference.
|
||||
Some(right) if !shift => {
|
||||
let d = dx.clamp(MIN_COL - left, (right - MIN_COL).max(MIN_COL - left));
|
||||
(left + d, Some(right - d))
|
||||
}
|
||||
// The right edge (or Shift): only the column on the left changes.
|
||||
_ => ((left + dx).clamp(MIN_COL, MAX_LEN), None),
|
||||
};
|
||||
if let Some(x) = w.get_mut(line - 1) {
|
||||
*x = new_left;
|
||||
}
|
||||
if let (Some(r), Some(x)) = (new_right, w.get_mut(line)) {
|
||||
*x = r;
|
||||
}
|
||||
Plan { widths: Some(w), heights: Vec::new() }
|
||||
}
|
||||
// A border of selected cells: it moves in their rows only, as far as all of them let
|
||||
// it, each row sharing the move out among its own cells.
|
||||
Edge::Column(line) if !self.only.is_empty() => {
|
||||
let rows: Vec<(usize, Vec<f32>, usize)> = self
|
||||
.only
|
||||
.iter()
|
||||
.filter_map(|(r, lines)| {
|
||||
let i = lines.iter().position(|g| *g == line)?.checked_sub(1)?;
|
||||
let xs: Vec<f32> = lines.iter().map(|g| self.cols.get(*g).copied()).collect::<Option<_>>()?;
|
||||
Some((*r, xs.windows(2).map(|x| (x[1] - x[0]).max(0.0)).collect(), i))
|
||||
})
|
||||
.collect();
|
||||
let d = rows.iter().filter_map(|(_, w, i)| share_out(&mut w.clone(), *i, dx, share)).min_by(|a, b| a.abs().total_cmp(&b.abs()));
|
||||
let Some(d) = d else { return Plan::default() };
|
||||
let cells = rows
|
||||
.into_iter()
|
||||
.map(|(r, mut w, i)| {
|
||||
share_out(&mut w, i, d, share);
|
||||
(r, w)
|
||||
})
|
||||
.collect();
|
||||
Plan { cells, ..Plan::default() }
|
||||
}
|
||||
Edge::Column(line) => match line.checked_sub(1).and_then(|i| share_out(&mut w, i, dx, share)) {
|
||||
Some(_) => Plan { widths: Some(w), ..Plan::default() },
|
||||
None => Plan::default(),
|
||||
},
|
||||
Edge::Row(row) => {
|
||||
let Some(r) = self.rows.iter().find(|r| r.row == row) else { return Plan::default() };
|
||||
Plan { heights: vec![(row, (r.bottom - r.top + dy).clamp(MIN_ROW, MAX_LEN))], ..Plan::default() }
|
||||
Plan { widths: None, heights: vec![(row, (r.bottom - r.top + dy).clamp(MIN_ROW, MAX_LEN))] }
|
||||
}
|
||||
Edge::Corner => {
|
||||
let (width, height) = (self.right() - self.left(), self.bottom() - self.top());
|
||||
|
|
@ -189,119 +138,10 @@ impl ResizeTarget {
|
|||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Plan { widths, heights, ..Plan::default() }
|
||||
Plan { widths, heights }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page x of grid line `line` once `plan` is applied: in row `row`, when that row's border
|
||||
/// moves on its own (`Plan::cells`), else in every row.
|
||||
pub fn planned_x(&self, plan: &Plan, row: Option<usize>, line: usize) -> f32 {
|
||||
let left = self.left() + plan.indent_by.unwrap_or(0.0);
|
||||
if let Some(r) = row
|
||||
&& let Some((_, w)) = plan.cells.iter().find(|(c, _)| *c == r)
|
||||
&& let Some(k) = self.only.iter().find(|(o, _)| *o == r).and_then(|(_, lines)| lines.iter().position(|g| *g == line))
|
||||
{
|
||||
return left + w.iter().take(k).sum::<f32>();
|
||||
}
|
||||
match &plan.widths {
|
||||
Some(w) => left + w.iter().take(line).sum::<f32>(),
|
||||
None => self.cols.get(line).copied().unwrap_or(left),
|
||||
}
|
||||
}
|
||||
|
||||
/// When the selection runs across cells of this table (Word selects them whole) and the
|
||||
/// column border grabbed at page y `y` is one of theirs, in one of their rows, the drag
|
||||
/// moves it only in those rows (`only`), as Word does with selected cells' borders. Not the
|
||||
/// table's left edge, which moves the whole table, nor a border of a cell merged down into
|
||||
/// rows outside the selection. Text selected within one cell isn't a cell selection.
|
||||
pub fn limit_to_selection(&mut self, s: &Session, y: f32) {
|
||||
self.only.clear();
|
||||
let Edge::Column(line) = self.edge else { return };
|
||||
let (a, b) = s.sel.ordered();
|
||||
if line == 0 || s.sel.is_collapsed() || a.story != self.story || b.story != self.story {
|
||||
return;
|
||||
}
|
||||
let (Some(t), Some((ra, ca)), Some((rb, cb))) =
|
||||
(s.doc.table(self.story, &self.table), cell_in(&self.table, &a.path), cell_in(&self.table, &b.path))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if (ra, ca) == (rb, cb) {
|
||||
return;
|
||||
}
|
||||
let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span);
|
||||
let (ga, gb) = (t.grid_col(ra, ca), t.grid_col(rb, cb));
|
||||
let (lo, hi) = (ga.min(gb), (ga + span(ra, ca)).max(gb + span(rb, cb)));
|
||||
let (r0, r1) = (ra.min(rb), ra.max(rb));
|
||||
let here = self.rows.iter().find(|r| y >= r.top && y <= r.bottom).map(|r| r.row);
|
||||
if line < lo || line > hi || !here.is_some_and(|r| (r0..=r1).contains(&r)) {
|
||||
return;
|
||||
}
|
||||
let mut only = Vec::new();
|
||||
for r in r0..=r1 {
|
||||
let Some(row) = t.rows.get(r) else { break };
|
||||
let mut lines = vec![0usize];
|
||||
for cell in &row.cells {
|
||||
let g = lines.last().copied().unwrap_or(0);
|
||||
let end = g.saturating_add(cell.span());
|
||||
// A merged cell that goes on above or below the selection keeps one width.
|
||||
let below =
|
||||
|| t.cell_at_grid(r + 1, g).and_then(|c| t.rows.get(r + 1)?.cells.get(c)).is_some_and(|c| c.props.vmerge == VMerge::Continue);
|
||||
let past = (r == r0 && cell.props.vmerge == VMerge::Continue) || (r == r1 && cell.props.vmerge != VMerge::None && below());
|
||||
if past && (g == line || end == line) {
|
||||
return;
|
||||
}
|
||||
lines.push(end);
|
||||
}
|
||||
only.push((r, lines));
|
||||
}
|
||||
self.only = only;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the border right of column `i` of `w` (column widths, left to right) by up to `dx`
|
||||
/// points, `share` saying who makes up the difference, and no column narrower than `MIN_COL`:
|
||||
/// how far it moved, or `None` when there's no column `i`.
|
||||
fn share_out(w: &mut [f32], i: usize, dx: f32, share: Share) -> Option<f32> {
|
||||
let left = *w.get(i)?;
|
||||
let rest: f32 = w.iter().skip(i + 1).sum();
|
||||
let narrowest = w.iter().skip(i + 1).copied().fold(f32::INFINITY, f32::min);
|
||||
// On the table's right edge (or with no room on the right) only the left column changes.
|
||||
let share = match share {
|
||||
Share::Neighbour if i + 1 < w.len() => Share::Neighbour,
|
||||
Share::Proportional if rest > 0.0 && narrowest > 0.0 => Share::Proportional,
|
||||
_ => Share::LeftOnly,
|
||||
};
|
||||
let least = MIN_COL - left;
|
||||
let d = match share {
|
||||
Share::Neighbour => dx.clamp(least, (w.get(i + 1).copied().unwrap_or(0.0) - MIN_COL).max(least)),
|
||||
// As far as the narrowest column on the right, shrunk in proportion, stays `MIN_COL`.
|
||||
Share::Proportional => dx.clamp(least, (rest * (1.0 - (MIN_COL / narrowest).min(1.0))).max(least)),
|
||||
Share::LeftOnly => (left + dx).clamp(MIN_COL, MAX_LEN) - left,
|
||||
};
|
||||
if let Some(x) = w.get_mut(i) {
|
||||
*x = left + d;
|
||||
}
|
||||
match share {
|
||||
Share::Neighbour => {
|
||||
if let Some(x) = w.get_mut(i + 1) {
|
||||
*x -= d;
|
||||
}
|
||||
}
|
||||
Share::Proportional => w.iter_mut().skip(i + 1).for_each(|x| *x *= (rest - d) / rest),
|
||||
Share::LeftOnly => {}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// The (row, cell) of table `tp` that a position at `p` is in.
|
||||
fn cell_in(tp: &Path, p: &Path) -> Option<(usize, usize)> {
|
||||
let n = tp.0.len();
|
||||
if p.0.len() < n + 3 || !p.0.starts_with(&tp.0) {
|
||||
return None;
|
||||
}
|
||||
Some((*p.0.get(n)? as usize, *p.0.get(n + 1)? as usize))
|
||||
}
|
||||
|
||||
/// A table cell's area on a page.
|
||||
|
|
@ -404,7 +244,7 @@ pub(crate) fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<Resiz
|
|||
let mine: Vec<&CellBox> = boxes.iter().filter(|b| b.story == story && b.table == *path).collect();
|
||||
let (cols, rows) = geometry(t, &mine)?;
|
||||
let ends_here = rows.last().is_some_and(|r| r.row + 1 == t.rows.len());
|
||||
Some(ResizeTarget { edge: Edge::Corner, story, table: path.clone(), cols, rows, ends_here, only: Vec::new() })
|
||||
Some(ResizeTarget { edge: Edge::Corner, story, table: path.clone(), cols, rows, ends_here })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -453,11 +293,8 @@ pub fn resize_target(l: &DocLayout, doc: &Document, page: usize, x: f32, y: f32,
|
|||
// Column borders: in the row under the pointer, only lines that are cell borders there.
|
||||
let row_here = target.rows.iter().find(|r| y >= r.top && y <= r.bottom).cloned();
|
||||
if let Some(r) = &row_here {
|
||||
for line in 0..target.cols.len() {
|
||||
// The table's left edge is grabbed from outside it (a point in, for the border's
|
||||
// width): inside starts the first cell.
|
||||
for line in 1..target.cols.len() {
|
||||
if r.edges.contains(&line)
|
||||
&& (line > 0 || x <= left + 1.0)
|
||||
&& let Some(cx) = target.cols.get(line).copied()
|
||||
{
|
||||
offer((x - cx).abs(), Edge::Column(line), &mut target);
|
||||
|
|
@ -479,18 +316,16 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?;
|
||||
let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?);
|
||||
let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?);
|
||||
// Ctrl wins over Shift, as in Word.
|
||||
let share = if p::bool(v, "ctrl").unwrap_or(false) { Share::Proportional } else { Share::from(p::bool(v, "shift").unwrap_or(false)) };
|
||||
let shift = p::bool(v, "shift").unwrap_or(false);
|
||||
let reach = p::f32(v, "reach").unwrap_or(REACH);
|
||||
let l = s.layout();
|
||||
if l.pages.get(page).is_none() {
|
||||
return Err(CmdError::Params(format!("no page {page}")));
|
||||
}
|
||||
let mut target =
|
||||
let target =
|
||||
resize_target(&l, &s.doc, page, x, y, reach).ok_or_else(|| CmdError::Failed("there's no table border or resize handle there".into()))?;
|
||||
target.limit_to_selection(s, y);
|
||||
let plan = target.plan(to_x - x, to_y - y, share);
|
||||
if plan.widths.is_none() && plan.heights.is_empty() && plan.cells.is_empty() {
|
||||
let plan = target.plan(to_x - x, to_y - y, shift);
|
||||
if plan.widths.is_none() && plan.heights.is_empty() {
|
||||
return sel_result(s);
|
||||
}
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
|
|
@ -505,21 +340,7 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
}
|
||||
}
|
||||
let moved = plan.indent_by.is_some_and(|d| move_left_edge(t, d));
|
||||
if !plan.cells.is_empty() {
|
||||
regrid(t, &target.cols, &plan.cells)?;
|
||||
}
|
||||
touch(s, &target)?;
|
||||
// Where a moved left edge lands isn't always its indent or offset alone (a floating table's
|
||||
// anchor): measure where it came to and put it right once.
|
||||
if moved
|
||||
&& let Some(d) = plan.indent_by
|
||||
&& let Some(got) = drawn_left(&s.layout(), page, &target)
|
||||
&& (got - (target.left() + d)).abs() > 0.05
|
||||
{
|
||||
move_left_edge(s.doc.table_mut(target.story, &target.table)?, target.left() + d - got);
|
||||
touch(s, &target)?;
|
||||
}
|
||||
if plan.heights.is_empty() {
|
||||
return sel_result(s);
|
||||
}
|
||||
|
|
@ -557,84 +378,6 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Moves `t`'s left edge `by` points: its indent, or a floating table's offset; whether it did.
|
||||
/// A centred or right-aligned table keeps its alignment, as in Word: the first column's new width
|
||||
/// is all that changes, and the table stays centred or on its right edge.
|
||||
fn move_left_edge(t: &mut Table, by: f32) -> bool {
|
||||
match t.props.float.as_mut() {
|
||||
Some(f) => {
|
||||
f.h_align = None;
|
||||
f.x = (f.x + by).clamp(-MAX_LEN, MAX_LEN);
|
||||
true
|
||||
}
|
||||
None if matches!(t.props.align, Some(Align::Center | Align::Right)) => false,
|
||||
None => {
|
||||
t.props.indent = Some((t.props.indent.unwrap_or(0.0) + by).clamp(-MAX_LEN, MAX_LEN));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `t`'s grid when some rows' cells change width on their own: `rows` gives their new
|
||||
/// widths, left to right; the other rows keep their cells' borders where they are drawn (the
|
||||
/// grid lines at `cols`). The grid gets a line wherever a row has a cell border, and each cell
|
||||
/// spans the columns between its own.
|
||||
fn regrid(t: &mut Table, cols: &[f32], rows: &[(usize, Vec<f32>)]) -> Result<(), CmdError> {
|
||||
let left = cols.first().copied().unwrap_or(0.0);
|
||||
let last = cols.len().saturating_sub(1);
|
||||
// Every row's cell borders, measured from the table's left edge.
|
||||
let borders: Vec<Vec<f32>> = t
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| match rows.iter().find(|(i, w)| *i == r && w.len() == row.cells.len()) {
|
||||
Some((_, w)) => std::iter::once(0.0)
|
||||
.chain(w.iter().scan(0.0, |x, cw| {
|
||||
*x += cw;
|
||||
Some(*x)
|
||||
}))
|
||||
.collect::<Vec<f32>>(),
|
||||
None => std::iter::once(0.0)
|
||||
.chain(row.cells.iter().scan(0usize, |g, c| {
|
||||
*g = g.saturating_add(c.span());
|
||||
Some(cols.get((*g).min(last)).copied().unwrap_or(left) - left)
|
||||
}))
|
||||
.collect::<Vec<f32>>(),
|
||||
})
|
||||
.collect();
|
||||
let mut lines: Vec<f32> = borders.iter().flatten().copied().collect();
|
||||
lines.sort_by(f32::total_cmp);
|
||||
lines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||||
if lines.len() > MAX_COLS + 1 {
|
||||
return Err(CmdError::Failed(format!("a table can't have more than {MAX_COLS} columns")));
|
||||
}
|
||||
let line_at = |x: f32| lines.iter().enumerate().min_by(|(_, a), (_, b)| (*a - x).abs().total_cmp(&(*b - x).abs())).map_or(0, |(i, _)| i);
|
||||
let mut spans: Vec<Vec<u32>> = Vec::with_capacity(borders.len());
|
||||
for b in &borders {
|
||||
let at: Vec<usize> = b.iter().map(|x| line_at(*x)).collect();
|
||||
let row: Vec<u32> = at.windows(2).map(|g| g[1].saturating_sub(g[0]) as u32).collect();
|
||||
if row.contains(&0) {
|
||||
return Err(CmdError::Failed("the cells there are too narrow to move that border".into()));
|
||||
}
|
||||
spans.push(row);
|
||||
}
|
||||
t.grid = lines.windows(2).map(|x| x[1] - x[0]).collect();
|
||||
for (row, row_spans) in t.rows.iter_mut().zip(spans) {
|
||||
for (cell, span) in row.cells.iter_mut().zip(row_spans) {
|
||||
cell.props.span = span;
|
||||
}
|
||||
}
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page x of the target table's left edge on `page`, as drawn.
|
||||
fn drawn_left(l: &DocLayout, page: usize, target: &ResizeTarget) -> Option<f32> {
|
||||
cell_boxes(l, page).iter().filter(|b| b.story == target.story && b.table == target.table).map(|b| b.rect.x).min_by(f32::total_cmp)
|
||||
}
|
||||
|
||||
/// Make `widths` (one per grid column) the table's column widths, as drawn: no preferred table
|
||||
/// width scales them again.
|
||||
pub(crate) fn set_widths(t: &mut Table, widths: Vec<f32>) {
|
||||
|
|
@ -830,128 +573,4 @@ mod tests {
|
|||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start);
|
||||
}
|
||||
|
||||
/// With Ctrl the columns right of the border share the change in proportion to their
|
||||
/// widths, so the table keeps its width (Ctrl wins over Shift); none gets narrower than the
|
||||
/// minimum.
|
||||
#[test]
|
||||
fn ctrl_shares_a_border_drag_among_the_columns_right_of_it() {
|
||||
let mut s = table_session(1, 4);
|
||||
let (a, b, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 3));
|
||||
let y = a.y + a.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": y, "toX": a.right() + 30.0, "toY": y, "ctrl": true, "shift": true}))
|
||||
.unwrap();
|
||||
let a1 = cell_rect(&mut s, 0, 0);
|
||||
assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
for c in 1..4 {
|
||||
let w = cell_rect(&mut s, 0, c).w;
|
||||
assert!((w - (b.w - 10.0)).abs() < 1.0, "column {c}: {} → {w}", b.w);
|
||||
}
|
||||
assert!((cell_rect(&mut s, 0, 3).right() - d.right()).abs() < 1.0, "the table keeps its width");
|
||||
// In proportion: 40 and 80 give up 10 and 20 of a 30-point move.
|
||||
let mut w = [100.0, 40.0, 80.0];
|
||||
assert_eq!(share_out(&mut w, 0, 30.0, Share::Proportional), Some(30.0));
|
||||
assert!((w[1] - 30.0).abs() < 0.01 && (w[2] - 60.0).abs() < 0.01, "{w:?}");
|
||||
// As far as the narrowest stays at the minimum.
|
||||
let mut w = [100.0, 12.0, 120.0];
|
||||
share_out(&mut w, 0, 500.0, Share::Proportional);
|
||||
assert!((w[1] - MIN_COL).abs() < 0.01 && (w.iter().sum::<f32>() - 232.0).abs() < 0.01, "{w:?}");
|
||||
assert_eq!(share_out(&mut w, 3, 1.0, Share::Proportional), None);
|
||||
}
|
||||
|
||||
/// The table's left edge, grabbed from just outside it, moves the table in or out: its
|
||||
/// indent changes and the first column makes up the difference, so the other borders stay.
|
||||
/// A centred or right-aligned table keeps its alignment, as in Word: only the first column
|
||||
/// changes, and the table stays centred or on its right edge.
|
||||
#[test]
|
||||
fn the_left_edge_moves_the_indent_and_the_first_column() {
|
||||
let mut s = table_session(2, 3);
|
||||
let start = first_table(&s);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
let y = a.y + a.h / 2.0;
|
||||
let l = s.layout();
|
||||
assert_eq!(resize_target(&l, &s.doc, 0, a.x - 2.0, y, REACH).map(|t| t.edge), Some(Edge::Column(0)));
|
||||
assert!(resize_target(&l, &s.doc, 0, a.x + 2.5, y, REACH).is_none(), "inside the first cell");
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.x - 1.0, "y": y, "toX": a.x + 35.0, "toY": y})).unwrap();
|
||||
let (a1, b1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
assert!((a1.x - (a.x + 36.0)).abs() < 0.5 && (a1.right() - a.right()).abs() < 0.5, "{a:?} → {a1:?}");
|
||||
assert!((b1.x - b.x).abs() < 0.5 && (b1.w - b.w).abs() < 0.5, "the other columns stay: {b:?} → {b1:?}");
|
||||
assert!((cell_rect(&mut s, 1, 0).x - a1.x).abs() < 0.01, "the whole table moved in");
|
||||
assert!((first_table(&s).props.indent.unwrap() - 36.0).abs() < 0.5, "{:?}", first_table(&s).props.indent);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "one undo step");
|
||||
s.run("table.properties", &json!({"align": "center", "width": 300})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
let y = c.y + c.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x - 20.0, "toY": y})).unwrap();
|
||||
let (c1, e1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
assert!((c1.w - (c.w + 20.0)).abs() < 0.5, "the first column takes the drag: {c:?} → {c1:?}");
|
||||
assert!((e1.w - e.w).abs() < 0.5, "the other columns keep their widths");
|
||||
assert!(((c1.x + e1.right()) - (c.x + e.right())).abs() < 1.0, "the table stays centred: {c:?} {e:?} → {c1:?} {e1:?}");
|
||||
assert_eq!(first_table(&s).props.align, Some(Align::Center));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("table.properties", &json!({"align": "right"})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x - 20.0, "toY": y})).unwrap();
|
||||
let (c1, e1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
assert!((c1.x - (c.x - 20.0)).abs() < 0.5 && (e1.right() - e.right()).abs() < 0.5, "only the left edge moves: {c:?} → {c1:?}");
|
||||
assert_eq!(first_table(&s).props.align, Some(Align::Right));
|
||||
}
|
||||
|
||||
/// With the selection across cells, a border of theirs moves only in their rows: the grid
|
||||
/// gets a line for those rows' border and the other rows keep theirs. Text within one cell
|
||||
/// isn't a cell selection; a border grabbed in a row outside the selection moves in the
|
||||
/// whole column.
|
||||
#[test]
|
||||
fn with_cells_selected_their_border_moves_only_in_their_rows() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (b, d) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 2, 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": "Wide"})).unwrap();
|
||||
let start = first_table(&s);
|
||||
let (b, c) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
let y = b.y + b.h / 2.0;
|
||||
let drag = json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y});
|
||||
s.run("select.text", &json!({"text": "Wide"})).unwrap();
|
||||
s.run("table.resizeDrag", &drag).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "text in one cell: every row");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// From the second row's cell down into the third's.
|
||||
s.run("caret.set", &json!({"page": 0, "x": d.x + d.w / 2.0, "y": d.y + d.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.resizeDrag", &drag).unwrap();
|
||||
for r in [1, 2] {
|
||||
let (b1, c1) = (cell_rect(&mut s, r, 1), cell_rect(&mut s, r, 2));
|
||||
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "row {r}: {b1:?} {c1:?}");
|
||||
}
|
||||
assert!((cell_rect(&mut s, 0, 1).right() - b.right()).abs() < 0.5, "the first row keeps its border");
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.grid.len(), 4, "{:?}", t.grid);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [1, 1, 2]);
|
||||
assert_eq!(t.rows[2].cells.iter().map(Cell::span).collect::<Vec<_>>(), [1, 2, 1]);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
let ya = a.y + a.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": ya, "toX": a.right() - 10.0, "toY": ya})).unwrap();
|
||||
assert!((cell_rect(&mut s, 1, 0).w - (a.w - 10.0)).abs() < 1.0, "a row outside the selection: the whole column");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "one undo step each");
|
||||
}
|
||||
|
||||
/// A cell merged down past the selection keeps one width: a border of it moves in the whole
|
||||
/// column.
|
||||
#[test]
|
||||
fn a_border_of_a_cell_merged_past_the_selection_moves_in_every_row() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 1), cell_rect(&mut s, 1, 1));
|
||||
s.run("caret.set", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.y + a.h / 2.0})).unwrap();
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.merge", &json!({})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 1, 2), cell_rect(&mut s, 2, 2));
|
||||
let y = c.y + c.h / 2.0;
|
||||
s.run("caret.set", &json!({"page": 0, "x": c.x + c.w / 2.0, "y": y})).unwrap();
|
||||
s.run("caret.set", &json!({"page": 0, "x": e.x + e.w / 2.0, "y": e.y + e.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x + 20.0, "toY": y})).unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -378,11 +378,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#,
|
||||
)
|
||||
.pure(),
|
||||
// Word's Search box in the title bar ("Tell me what you want to do"), Alt+Q (#819).
|
||||
CommandSpec::new("tools.search", "Search", "Title Bar", search_commands)
|
||||
.key(super::non_mac("Alt+Q"))
|
||||
.params(r#"{"query"?: string} → {matches: [{id, label, location, shortcut}]} (every word of the query in the label, location or id; at most 50); no query opens the Search box"#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
|
||||
|
|
@ -1418,31 +1413,6 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
|
|||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// `tools.search`: the commands whose label, location or id hold every word of `query`, or,
|
||||
/// with no query, the Search box (`ui_requests` → the UI's command search).
|
||||
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(query) = p::str(v, "query") else {
|
||||
s.ui_requests.push(json!({"open": "commands"}));
|
||||
return Ok(json!({"matches": []}));
|
||||
};
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||
let matches: Vec<Value> = if words.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.registry
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let hay = format!("{} {} {}", c.label, c.location, c.id).to_lowercase();
|
||||
words.iter().all(|w| hay.contains(w.as_str()))
|
||||
})
|
||||
.take(50)
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut}))
|
||||
.collect()
|
||||
};
|
||||
Ok(json!({"matches": matches}))
|
||||
}
|
||||
|
||||
/// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access
|
||||
/// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does
|
||||
/// what they ask (no dialog).
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
|||
doc.ensure_nonempty();
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if ext.is_empty() || signature_contradicts(&ext, bytes) {
|
||||
if ext.is_empty() {
|
||||
return open_by_content(name, &ext, bytes, password);
|
||||
}
|
||||
let mut doc = match ext.as_str() {
|
||||
|
|
@ -163,31 +163,6 @@ pub(crate) fn fit_pictures(doc: &mut Document) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Does the file hold a different document format than its Word or RTF extension says? An RTF
|
||||
/// file saved as `.doc` (as accounting systems and report generators often save their
|
||||
/// documents), a `.docx` package renamed `.doc` or a Word 97-2003 file renamed `.docx`: Word opens
|
||||
/// each by what it holds, so they go through [`open_by_content`] instead of failing.
|
||||
fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
|
||||
const ZIP: &[u8] = b"PK\x03\x04";
|
||||
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
let held = if bytes.starts_with(ZIP) {
|
||||
"zip"
|
||||
} else if bytes.starts_with(OLE) {
|
||||
"ole"
|
||||
} else if bytes.starts_with(b"{\\rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let expected = match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => "zip",
|
||||
"doc" | "dot" => "ole",
|
||||
"rtf" => "rtf",
|
||||
_ => return false,
|
||||
};
|
||||
held != expected
|
||||
}
|
||||
|
||||
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
|
||||
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
|
||||
/// other text through the plain-text import with encoding detection (#569). Anything else is
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn samples_are_valid() {
|
||||
assert_eq!(sample_document().word_count(), 361);
|
||||
for d in [sample_document(), letter(), resume(), report()] {
|
||||
assert!(d.word_count() > 5);
|
||||
let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
|
||||
|
|
|
|||
|
|
@ -813,22 +813,6 @@ fn clipboard_round_trip() {
|
|||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "ok J\u{F0FC}"}));
|
||||
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(s.clipboard_text, "ok \u{263A}\u{2713}");
|
||||
// Pasting our own copy back (the system clipboard holds the mapped text) keeps the stored
|
||||
// Wingdings characters, not the Unicode ones.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let clip = s.clipboard_text.clone();
|
||||
run(&mut s, "edit.paste", json!({"text": clip}));
|
||||
assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}");
|
||||
}
|
||||
|
||||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||||
|
|
@ -1449,55 +1433,6 @@ fn accept_all_keeps_a_tracked_paragraph_break() {
|
|||
assert_eq!(run(&mut s, "review.changes", json!({})), json!([]));
|
||||
}
|
||||
|
||||
/// Issue #864: five paragraphs, a tracked deletion from the end of the first to the end of the
|
||||
/// third. The two breaks inside the range and the text of the second and third paragraphs are
|
||||
/// deleted; the third paragraph's own break is not.
|
||||
fn tracked_delete_from_paragraph_end() -> Session {
|
||||
let mut s = s();
|
||||
for (i, word) in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": word}));
|
||||
}
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
let range = json!({"anchor": {"path": [0], "off": 5, "story": "body"}, "focus": {"path": [2], "off": 7, "story": "body"}});
|
||||
run(&mut s, "select.range", range);
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph() {
|
||||
let mut s = tracked_delete_from_paragraph_end();
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"], "tracked deletions stay in the text");
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Delta", "Echo"]);
|
||||
assert!(s.doc.revisions.is_empty());
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.mark.ins.is_none() && p.mark.del.is_none()));
|
||||
// Rejecting instead restores everything, as it already did.
|
||||
let mut s = tracked_delete_from_paragraph_end();
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph() {
|
||||
// Same cause as #864 seen from the other side: rejecting the typed text emptied the
|
||||
// paragraph, and its mark picked up the text's insertion, so it was rejected as well.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "One"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Three"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
run(&mut s, "text.insert", json!({"text": "Two"}));
|
||||
assert_eq!(body_texts(&s), ["One", "Two", "Three"]);
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["One", "", "Three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_one_paragraph_break_and_rejecting_a_typed_paragraph() {
|
||||
// Reject with the caret on the break (Next Change selects it), or at the end of its paragraph.
|
||||
|
|
@ -2006,25 +1941,6 @@ fn word_count_includes_text_boxes_by_default() {
|
|||
assert_eq!(s.word_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))];
|
||||
let mut s = Session::new(d);
|
||||
|
||||
let v = run(&mut s, "review.wordCount", json!({}));
|
||||
// Generated label "1." + "Apple" = 2 words.
|
||||
assert_eq!(v["words"].as_u64(), Some(2));
|
||||
assert_eq!(s.word_count(), 2);
|
||||
// Generated list label "1." is formatting metadata and not run characters,
|
||||
// so Word excludes it from character counts: "Apple" has 5 characters.
|
||||
assert_eq!(v["characters"].as_u64(), Some(5));
|
||||
assert_eq!(v["charactersWithSpaces"].as_u64(), Some(5));
|
||||
}
|
||||
|
||||
/// The laid-out area of the only text box.
|
||||
fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
let l = s.layout();
|
||||
|
|
@ -2539,35 +2455,6 @@ fn styles_apply_multi_paragraph_selection() {
|
|||
}
|
||||
}
|
||||
|
||||
/// #865: `styles.modify` takes a colour the way `format.color` does (`RRGGBB`, `#RRGGBB`, `auto`),
|
||||
/// still reads the serialized forms, and rejects a bad colour without touching the style.
|
||||
#[test]
|
||||
fn modify_style_takes_hex_colours_like_format_color() {
|
||||
use wordcraft_doc::{CharProps, Rgb, TextColor};
|
||||
let mut s = s();
|
||||
let color = |s: &Session| s.doc.styles.get("Heading1").and_then(|st| st.chr.color);
|
||||
let modify = |s: &mut Session, c: serde_json::Value| s.run("styles.modify", &json!({"style": "Heading1", "chr": {"color": c}}));
|
||||
|
||||
modify(&mut s, json!("1B1611")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
|
||||
modify(&mut s, json!("auto")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Auto));
|
||||
modify(&mut s, json!("#1B1611")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
|
||||
modify(&mut s, json!("Auto")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Auto));
|
||||
modify(&mut s, json!({"Rgb": [192, 0, 0]})).unwrap();
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
assert_eq!(color(&s), Some(red));
|
||||
assert_eq!(s.doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).color, red);
|
||||
|
||||
for bad in [json!("1B16"), json!("not a colour"), json!({"Rgb": "1B1611"}), json!(7)] {
|
||||
let r = modify(&mut s, bad.clone());
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}");
|
||||
assert_eq!(color(&s), Some(red), "{bad} leaves the style alone");
|
||||
}
|
||||
}
|
||||
|
||||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||||
|
|
@ -3829,31 +3716,6 @@ fn colour_commands_take_any_custom_hex() {
|
|||
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
|
||||
}
|
||||
|
||||
/// A document whose content is another format than its extension says opens by what it holds,
|
||||
/// as in Word: RTF saved as `.doc` (accounting systems and report generators save their documents
|
||||
/// that way), a `.docx` package renamed `.doc`, and RTF renamed `.docx`.
|
||||
#[test]
|
||||
fn documents_open_by_content_when_the_extension_is_wrong() {
|
||||
let text = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
|
||||
let rtf = b"{\\rtf1\\ansi\\ansicpg1251\\deff0{\\fonttbl{\\f0 Times New Roman;}}\\f0 \\'d1\\'f7\\'e5\\'f2 \\'b9 12\\par Invoice line\\par}";
|
||||
for name in ["Счет 12.doc", "report.DOC", "letter.dot", "renamed.docx"] {
|
||||
let doc = crate::io::open_bytes(name, rtf).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let t = text(&doc);
|
||||
assert!(t.contains("Счет № 12") && t.contains("Invoice line"), "{name}: {t:?}");
|
||||
}
|
||||
// A Word package renamed `.doc` or `.rtf`.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Packaged text"}));
|
||||
let docx = crate::io::save_bytes("a.docx", &s.doc).unwrap();
|
||||
for name in ["package.doc", "package.rtf"] {
|
||||
let doc = crate::io::open_bytes(name, &docx).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(text(&doc).contains("Packaged text"), "{name}");
|
||||
}
|
||||
// Matching content still goes the usual way, and garbage stays an error.
|
||||
assert!(text(&crate::io::open_bytes("plain.rtf", rtf).unwrap()).contains("Invoice line"));
|
||||
assert!(crate::io::open_bytes("broken.doc", b"\x00\x01not a document").is_err());
|
||||
}
|
||||
|
||||
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
|
||||
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
|
||||
/// clean error, never garbage or a panic.
|
||||
|
|
@ -3953,7 +3815,7 @@ fn drag_after_multi_click_extends_by_units() {
|
|||
let before = text(&s);
|
||||
for v in [
|
||||
json!({}),
|
||||
json!({"unit": "page", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
|
||||
json!({"unit": "line", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
|
||||
json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}),
|
||||
json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}),
|
||||
] {
|
||||
|
|
@ -3962,39 +3824,6 @@ fn drag_after_multi_click_extends_by_units() {
|
|||
assert_eq!(text(&s), before);
|
||||
}
|
||||
|
||||
/// #856: a click in the selection bar selects the whole line (its trailing space; the
|
||||
/// paragraph mark on the last line); dragging extends by lines; triple-click takes the document.
|
||||
#[test]
|
||||
fn selection_bar_selects_and_drags_by_lines() {
|
||||
let mut s = s();
|
||||
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
|
||||
run(&mut s, "text.insert", json!({"text": format!("{}\rShort one.\rLast.", long.trim_end())}));
|
||||
let l = s.layout();
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
let line2 = l.selection_bar_at(c.page, 20.0, c.top + c.height * 1.5).unwrap();
|
||||
assert!(line2.off > 0);
|
||||
run(&mut s, "select.line", json!({"pos": line2.clone()}));
|
||||
let (a, b) = s.sel.ordered();
|
||||
assert_eq!(a, line2);
|
||||
assert_eq!(b.path, wordcraft_doc::Path::top(0));
|
||||
assert!(b.off > a.off && b.off < long.trim_end().len());
|
||||
assert_eq!(s.doc.para_at(&b).unwrap().text.as_bytes()[b.off - 1], b' ', "the trailing space is in");
|
||||
// The last line of a paragraph takes its mark.
|
||||
run(&mut s, "select.line", json!({"pos": Pos::body(1, 3)}));
|
||||
assert_eq!(s.sel.ordered(), (Pos::body(1, 0), Pos::body(2, 0)));
|
||||
// Drag from the "Short one." line down to "Last." and back up into the first paragraph.
|
||||
let (a, b) = s.sel.ordered();
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": Pos::body(2, 0)}));
|
||||
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(1, 0), Pos::body(2, 5)));
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": line2.clone()}));
|
||||
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(2, 0), line2));
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "document", "start": a.clone(), "end": b.clone(), "pos": Pos::body(0, 0)}));
|
||||
assert_eq!(s.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 5)));
|
||||
// Junk positions never panic.
|
||||
let _ = s.run("select.line", &json!({"pos": {"block": 99, "off": usize::MAX}}));
|
||||
let _ = s.run("select.line", &json!({"pos": 5}));
|
||||
}
|
||||
|
||||
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
|
||||
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
|
||||
/// again, and each step undoes.
|
||||
|
|
@ -4907,102 +4736,3 @@ fn spacing_commands_handle_automatic_spacing() {
|
|||
assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space");
|
||||
assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic");
|
||||
}
|
||||
|
||||
/// #712, as in Word: Ctrl+F (`edit.find` with no text) opens the Navigation pane and asks the UI
|
||||
/// to put the keyboard in its search box, not the dialog; Ctrl+H (`edit.replace` with nothing to
|
||||
/// replace) opens Find and Replace on its Replace tab instead of failing; Go To moves by pages from
|
||||
/// the caret's page (`pageBy`).
|
||||
#[test]
|
||||
fn find_replace_and_go_to_open_like_word() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
|
||||
run(&mut s, "edit.find", json!({}));
|
||||
assert!(s.view.nav_pane);
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"focus": "navSearch"})));
|
||||
assert!(!s.ui_requests.iter().any(|r| r.get("open").is_some()), "no dialog: {:?}", s.ui_requests);
|
||||
s.dirty = false;
|
||||
run(&mut s, "edit.findReplace", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "replace"})));
|
||||
assert!(!s.dirty, "opening the dialog changes nothing");
|
||||
assert!(s.run("edit.findReplace", &json!({"tab": "nope"})).is_err());
|
||||
let reg = s.registry.clone();
|
||||
assert_eq!(s.keymap.resolve(®, "Mod+H").map(|c| c.id), Some("edit.findReplace"));
|
||||
run(&mut s, "edit.goto", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "goto"})));
|
||||
// Replace with a "with" and the last search still replaces, as before.
|
||||
run(&mut s, "edit.find", json!({"text": "dog"}));
|
||||
run(&mut s, "edit.replace", json!({"with": "cow"}));
|
||||
assert_eq!(text(&s), "cat cow cat");
|
||||
|
||||
// Five pages; Go To +2 / -1 from where the caret is, clamped to the document.
|
||||
let mut s = super::tests::s();
|
||||
for i in 0..5 {
|
||||
if i > 0 {
|
||||
run(&mut s, "insert.pageBreak", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
|
||||
}
|
||||
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
|
||||
run(&mut s, "edit.goto", json!({"page": 1}));
|
||||
assert_eq!(page(&mut s), 1);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": 2}));
|
||||
assert_eq!(page(&mut s), 3);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": -1}));
|
||||
assert_eq!(page(&mut s), 2);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": 99}));
|
||||
assert_eq!(page(&mut s), 5, "past the end: the last page");
|
||||
run(&mut s, "edit.goto", json!({"pageBy": -99}));
|
||||
assert_eq!(page(&mut s), 1, "before the start: the first page");
|
||||
assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic");
|
||||
}
|
||||
|
||||
/// #802: Insert › Picture takes Windows Metafiles (and EMF) at their own frame size, stored under
|
||||
/// their own extension so a save writes them as WMF/EMF parts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn insert_picture_takes_a_windows_metafile() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
// One WMF record: size in words, function, parameters.
|
||||
let rec = |func: u16, params: &[i16]| {
|
||||
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
|
||||
v.extend(func.to_le_bytes());
|
||||
params.iter().for_each(|p| v.extend(p.to_le_bytes()));
|
||||
v
|
||||
};
|
||||
// A placeable WMF, 1440 × 720 units at 1440 per inch (72 × 36 points), drawing a rectangle.
|
||||
let body = [rec(0x041B, &[720, 1440, 0, 0]), rec(0, &[])].concat();
|
||||
let mut wmf = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
for v in [0i16, 0, 0, 1440, 720] {
|
||||
wmf.extend(v.to_le_bytes());
|
||||
}
|
||||
wmf.extend(1440u16.to_le_bytes());
|
||||
wmf.extend([0u8; 6]);
|
||||
let words = ((wmf.len() + 18 + body.len()) / 2) as u32;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
wmf.extend(v.to_le_bytes());
|
||||
}
|
||||
wmf.extend(words.to_le_bytes());
|
||||
wmf.extend(0u16.to_le_bytes());
|
||||
wmf.extend(5u32.to_le_bytes());
|
||||
wmf.extend(0u16.to_le_bytes());
|
||||
wmf.extend(body);
|
||||
let dir = scratch_dir("insert-wmf");
|
||||
let path = dir.join("logo.wmf");
|
||||
std::fs::write(&path, &wmf).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = run(&mut s, "insert.picture", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(72.0), Some(36.0)), "{r}");
|
||||
let key = r["media"].as_str().unwrap().to_string();
|
||||
assert!(key.ends_with(".wmf"), "{key}");
|
||||
assert_eq!(s.doc.media.get(&key).map(|b| b.len()), Some(wmf.len()));
|
||||
let mut placed = false;
|
||||
s.doc.objects_in_reading_order(&mut |o| placed |= matches!(o, InlineObject::Image { media, .. } if *media == key));
|
||||
assert!(placed, "the picture is in the text");
|
||||
|
||||
// Neither a picture nor a metafile: the same error as before, naming the formats.
|
||||
std::fs::write(&path, b"not a picture").unwrap();
|
||||
let err = s.run("insert.picture", &json!({"path": path.to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("WMF, EMF"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,17 +168,6 @@ impl FontFace {
|
|||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database
|
||||
/// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache.
|
||||
pub fn glyph_outline(&self, gid: u32) -> BezPath {
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = self.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
|
||||
}
|
||||
pen.0
|
||||
}
|
||||
/// Is `family` this face's typographic or legacy family name?
|
||||
pub fn named(&self, family: &str) -> bool {
|
||||
self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family)
|
||||
|
|
@ -866,12 +855,11 @@ fn style_italic(style: &str) -> bool {
|
|||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first,
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts), then the
|
||||
/// craft-fonts Simplified Chinese faces, for the ideographs the Japanese fonts lack (both empty
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts; empty
|
||||
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(crate::chinese_fonts()).map(|f| f.bytes);
|
||||
let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for f in enumerate_faces(data) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
|
|
@ -1208,71 +1196,6 @@ impl FontDb {
|
|||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a font list entry draws `family`'s name in: its Regular face (or the closest
|
||||
/// style), without loading the family (#848). A loaded family's face is shared; an installed
|
||||
/// one is read into a face of its own that the database doesn't keep, so its data is freed when
|
||||
/// the caller drops it. Browsing the font list (hundreds of families) then costs no lasting
|
||||
/// memory; only fonts actually used load for good. `None` when no font has that name, or its
|
||||
/// file is too large to read just for a preview.
|
||||
pub fn preview_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, "Regular") {
|
||||
return Some(f);
|
||||
}
|
||||
let english = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family));
|
||||
if let Some(f) = english.as_deref().and_then(|en| self.find(en, "Regular")) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(f) = self.unshared_face(family).or_else(|| english.as_deref().and_then(|en| self.unshared_face(en))) {
|
||||
return Some(f);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The installed `family`'s face closest to Regular, read from its file into a face the
|
||||
/// database doesn't keep (see [`FontDb::preview_face`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unshared_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
let (path, fam, style) = {
|
||||
let cat = self.read_catalog();
|
||||
let mut cands: Vec<&CatalogEntry> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
cands = cat.iter().filter(|c| c.legacy.eq_ignore_ascii_case(family)).collect();
|
||||
}
|
||||
let score = |c: &CatalogEntry| {
|
||||
if matches!(norm(&c.style).as_str(), "regular" | "normal" | "book" | "roman") {
|
||||
-1.0
|
||||
} else {
|
||||
(style_weight(&c.style) - 400.0).abs() + if style_italic(&c.style) { 1000.0 } else { 0.0 }
|
||||
}
|
||||
};
|
||||
let best = cands.into_iter().min_by(|a, b| score(a).total_cmp(&score(b)))?;
|
||||
(best.path.clone(), best.family.clone(), best.style.clone())
|
||||
};
|
||||
let data = Arc::new(read_fallback_file(&path)?);
|
||||
let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
|
||||
let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?;
|
||||
face.local_names = found.local;
|
||||
Some(Arc::new(face))
|
||||
}
|
||||
|
||||
/// Bytes of font data held by loaded faces (each file once; compiled-in fonts not counted).
|
||||
/// It grows as families load for use; font list previews leave it as is (#848).
|
||||
pub fn loaded_font_bytes(&self) -> usize {
|
||||
let mut seen: Vec<*const Vec<u8>> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for f in self.read_faces().iter() {
|
||||
if let FontBytes::Owned(d) = &f.bytes {
|
||||
let p = Arc::as_ptr(d);
|
||||
if !seen.contains(&p) {
|
||||
seen.push(p);
|
||||
total = total.saturating_add(d.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)? English and localized family
|
||||
/// names both count.
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
|
|
@ -1287,7 +1210,7 @@ impl FontDb {
|
|||
}
|
||||
|
||||
/// Is a face named `family` (its typographic or legacy family) loaded?
|
||||
pub fn is_loaded(&self, family: &str) -> bool {
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.named(family))
|
||||
}
|
||||
|
||||
|
|
@ -1645,7 +1568,13 @@ impl FontDb {
|
|||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = Arc::new(face.glyph_outline(gid));
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
|
|
|
|||
|
|
@ -8,15 +8,9 @@ const FAMILY: &str = "Sysfont Sans3";
|
|||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
renamed_to(file, FAMILY)
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
|
|
@ -275,10 +269,8 @@ fn fallback_reads_each_installed_font_file_once_however_many_characters_miss() {
|
|||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let found = db.fallback_for('\u{30000}', latin.id()).expect("the installed font maps it");
|
||||
assert_eq!((found.family.as_str(), found.style.as_str()), (FAMILY, "Regular"));
|
||||
// 2,000 distinct characters nothing maps (Supplementary Private Use Area-A: no font here,
|
||||
// bundled or craft-fonts, maps it; CJK Extension G after U+30000 is partly in Noto Sans CJK
|
||||
// SC): the index answers, no file is read again.
|
||||
for cp in 0xF_0001..0xF_0001 + 2000 {
|
||||
// 2,000 distinct characters nothing maps: the index answers, no file is read again.
|
||||
for cp in 0x30001..0x30001 + 2000 {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
assert!(db.fallback_for(c, latin.id()).is_none());
|
||||
}
|
||||
|
|
@ -411,48 +403,3 @@ fn east_asian_fonts_are_listed_for_the_asian_text_font_box() {
|
|||
assert!(db.face("Songti Test", "Regular").is_east_asian());
|
||||
assert_eq!(listed(&db), 1);
|
||||
}
|
||||
|
||||
/// #848: browsing the font list drew each family's name in its own face, loading every family
|
||||
/// shown for good (tens of megabytes per trip through the Font box). Preview faces are read for
|
||||
/// the preview alone: however many families are previewed, the database keeps no font data, and
|
||||
/// each preview face is freed when dropped.
|
||||
#[test]
|
||||
fn font_list_previews_load_nothing_for_good() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-{}-previews", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let families: Vec<String> = (0..40).map(|i| format!("Preview Fam{i:02}")).collect();
|
||||
for (i, f) in families.iter().enumerate() {
|
||||
std::fs::write(dir.join(format!("p{i}-Regular.ttf")), renamed_to("SourceSans3-Regular.ttf", f)).unwrap();
|
||||
std::fs::write(dir.join(format!("p{i}-Bold.ttf")), renamed_to("SourceSans3-Bold.ttf", f)).unwrap();
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let (faces, bytes) = (db.read_faces().len(), db.loaded_font_bytes());
|
||||
// Every family previewed many times over, as reopening the list does.
|
||||
for _ in 0..5 {
|
||||
for f in &families {
|
||||
let face = db.preview_face(f).expect("installed family previews");
|
||||
assert_eq!(face.family, *f);
|
||||
assert_eq!(face.style, "Regular", "the preview shows the Regular face");
|
||||
assert!(face.covers('P'));
|
||||
assert_eq!(Arc::strong_count(&face), 1, "nothing else keeps the preview face (or its data)");
|
||||
}
|
||||
}
|
||||
assert_eq!(db.read_faces().len(), faces, "previews load no faces");
|
||||
assert_eq!(db.loaded_font_bytes(), bytes, "previews keep no font data");
|
||||
assert!(db.preview_face("No Such Family").is_none());
|
||||
|
||||
// Using a font loads it once; previewing it again shares the loaded face.
|
||||
assert_eq!(db.face(&families[0], "Regular").family, families[0]);
|
||||
let used = db.loaded_font_bytes();
|
||||
assert!(used > bytes, "a used family's data is counted");
|
||||
let shared = db.preview_face(&families[0]).unwrap();
|
||||
assert!(Arc::strong_count(&shared) > 1, "a loaded family's preview is the shared face");
|
||||
for _ in 0..3 {
|
||||
for f in &families[1..] {
|
||||
drop(db.preview_face(f));
|
||||
}
|
||||
}
|
||||
assert_eq!(db.loaded_font_bytes(), used, "memory stays bounded across previews");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
|||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"];
|
||||
// Word's symbol fonts (Symbol, Wingdings…) are proprietary: their characters are drawn as their
|
||||
// Unicode equivalents (`wordcraft_doc::symbol_fonts`) from these faces or any face that has them.
|
||||
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Noto Sans Symbols 2", "Source Sans 3"];
|
||||
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"];
|
||||
// Persian / Arabic document fonts. Naskh (book) faces such as B Nazanin, B Lotus, B Zar and
|
||||
// Traditional Arabic, then the sans faces (B Yekan, B Titr, IRANSans) people use for
|
||||
// headings and screens. Open fonts with full Persian coverage first.
|
||||
|
|
@ -68,7 +66,7 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
|||
"arial" | "helvetica" | "arial nova" => ARIAL,
|
||||
"times new roman" | "times" => TIMES,
|
||||
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
|
||||
"symbol" | "wingdings" | "wingdings 2" | "wingdings 3" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"cambria"
|
||||
| "cambria math"
|
||||
| "georgia"
|
||||
|
|
@ -87,20 +85,6 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
|||
}
|
||||
}
|
||||
|
||||
/// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`,
|
||||
/// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for
|
||||
/// the font itself, and old Russian and Central European documents name them. Other names are
|
||||
/// returned unchanged.
|
||||
pub fn charset_base_family(family: &str) -> &str {
|
||||
const SUFFIXES: &[&str] = &[" cyr", " ce", " greek", " tur", " baltic", " (arabic)", " (hebrew)", " (vietnamese)"];
|
||||
let lower = family.trim_end().to_ascii_lowercase();
|
||||
SUFFIXES
|
||||
.iter()
|
||||
.find(|s| lower.ends_with(*s) && lower.len() > s.len())
|
||||
.and_then(|s| family.trim_end().get(..lower.len() - s.len()))
|
||||
.map_or(family, str::trim_end)
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
/// Persian font packs; matched by name only).
|
||||
fn is_persian_naskh(f: &str) -> bool {
|
||||
|
|
@ -208,14 +192,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
|||
return *r;
|
||||
}
|
||||
}
|
||||
let base = charset_base_family(family);
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
let s = substitutes(base).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
let s = substitutes(family).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
(s.to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
|
|
@ -227,6 +207,34 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
|||
r
|
||||
}
|
||||
|
||||
/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores
|
||||
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used
|
||||
/// when that font isn't installed: a substitute draws something else, or nothing, at the
|
||||
/// private-use code. `None` for other fonts and for codes not in the table.
|
||||
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
|
||||
let code = u32::from(c).checked_sub(0xF000)?;
|
||||
let u = match (family.to_ascii_lowercase().as_str(), code) {
|
||||
// Adobe Symbol encoding.
|
||||
("symbol", 0xA7) => '\u{2663}',
|
||||
("symbol", 0xA8) => '\u{2666}',
|
||||
("symbol", 0xA9) => '\u{2665}',
|
||||
("symbol", 0xAA) => '\u{2660}',
|
||||
("symbol", 0xAE) => '\u{2192}',
|
||||
("symbol", 0xB7) => '\u{2022}',
|
||||
("symbol", 0xD7) => '\u{22C5}',
|
||||
("symbol", 0xE0) => '\u{25CA}',
|
||||
// Wingdings characters common as list bullets.
|
||||
("wingdings", 0x6C) => '\u{25CF}',
|
||||
("wingdings", 0x6E) => '\u{25A0}',
|
||||
("wingdings", 0x76) => '\u{2756}',
|
||||
("wingdings", 0xA7) => '\u{25AA}',
|
||||
("wingdings", 0xD8) => '\u{27A2}',
|
||||
("wingdings", 0xFC) => '\u{2714}',
|
||||
_ => return None,
|
||||
};
|
||||
Some(u)
|
||||
}
|
||||
|
||||
/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent.
|
||||
fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
|
||||
(f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs())
|
||||
|
|
@ -296,21 +304,19 @@ mod tests {
|
|||
}
|
||||
// Other serif families are unchanged.
|
||||
assert_eq!(substitutes("Georgia").first(), Some(&"Caladea"));
|
||||
// Charset aliases are the font itself, so they substitute like it.
|
||||
assert_eq!(charset_base_family("Times New Roman Cyr"), "Times New Roman");
|
||||
assert_eq!(charset_base_family("Arial CYR"), "Arial");
|
||||
assert_eq!(charset_base_family("Courier New CE"), "Courier New");
|
||||
assert_eq!(charset_base_family("Arial (Hebrew)"), "Arial");
|
||||
assert_eq!(charset_base_family("Cyr"), "Cyr");
|
||||
assert_eq!(charset_base_family("Calibri"), "Calibri");
|
||||
assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_fonts_substitute_symbol_faces() {
|
||||
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
|
||||
assert!(substitutes(f).contains(&"Noto Sans Symbols 2"), "{f}");
|
||||
}
|
||||
fn symbol_font_chars_map_to_unicode() {
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}'));
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}'));
|
||||
// Same code, other font; unknown codes; ordinary characters.
|
||||
assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", 'a'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,6 @@ impl<'r> Builder<'r> {
|
|||
covered: false,
|
||||
header: name == "th",
|
||||
shading,
|
||||
borders: None,
|
||||
});
|
||||
fmt.background = None;
|
||||
self.containers.push(Vec::new());
|
||||
|
|
|
|||
|
|
@ -89,19 +89,6 @@ pub struct ListInfo {
|
|||
pub level: u8,
|
||||
}
|
||||
|
||||
/// Paragraph spacing and indents (points) that a source states on the paragraph itself (RTF);
|
||||
/// left out, they come from the style.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Spacing {
|
||||
pub before: Option<f32>,
|
||||
pub after: Option<f32>,
|
||||
pub line: Option<wordcraft_doc::props::LineSpacing>,
|
||||
pub left: Option<f32>,
|
||||
pub right: Option<f32>,
|
||||
/// First-line indent; negative = hanging.
|
||||
pub first: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Para {
|
||||
pub kind: Kind,
|
||||
|
|
@ -109,9 +96,6 @@ pub struct Para {
|
|||
pub align: Option<Align>,
|
||||
pub page_break: bool,
|
||||
pub inlines: Vec<Inline>,
|
||||
pub spacing: Spacing,
|
||||
/// Borders the source puts on the paragraph itself (RTF `\brdrb`…).
|
||||
pub borders: Option<wordcraft_doc::props::Borders>,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
|
|
@ -142,22 +126,6 @@ impl Para {
|
|||
}
|
||||
s
|
||||
}
|
||||
/// [`Self::text`] as it reads on screen: text set in a symbol font (Symbol, Wingdings…) as
|
||||
/// its Unicode equivalents ([`wordcraft_doc::symbol_fonts`]), for plain-text export.
|
||||
pub fn shown_text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for i in &self.inlines {
|
||||
match i {
|
||||
Inline::Text(t, f) => match f.font.as_deref() {
|
||||
Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)),
|
||||
_ => s.push_str(t),
|
||||
},
|
||||
Inline::Equation { linear: t, .. } => s.push_str(t),
|
||||
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inlines.iter().all(|i| matches!(i, Inline::Text(t, _) if t.is_empty()))
|
||||
}
|
||||
|
|
@ -203,13 +171,11 @@ pub struct Cell {
|
|||
pub covered: bool,
|
||||
pub header: bool,
|
||||
pub shading: Option<Rgb>,
|
||||
/// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style.
|
||||
pub borders: Option<wordcraft_doc::props::Borders>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,8 +365,6 @@ impl Builder<'_> {
|
|||
out.props.style = style;
|
||||
if p.kind == Kind::Rule {
|
||||
out.props.borders = Some(rule_borders());
|
||||
} else if p.borders.is_some() {
|
||||
out.props.borders = p.borders;
|
||||
}
|
||||
if let (Some(li), Some(num)) = (p.list, list_num) {
|
||||
if out.props.style.is_none() {
|
||||
|
|
@ -409,13 +373,6 @@ impl Builder<'_> {
|
|||
out.props.numbering = Some(NumRef { num, level: li.level.min(8) });
|
||||
}
|
||||
out.props.align = p.align;
|
||||
let sp = p.spacing;
|
||||
out.props.space_before = sp.before.or(out.props.space_before);
|
||||
out.props.space_after = sp.after.or(out.props.space_after);
|
||||
out.props.line_spacing = sp.line.or(out.props.line_spacing);
|
||||
out.props.indent_left = sp.left.or(out.props.indent_left);
|
||||
out.props.indent_right = sp.right.or(out.props.indent_right);
|
||||
out.props.indent_first = sp.first.or(out.props.indent_first);
|
||||
if p.page_break {
|
||||
out.props.page_break_before = Some(true);
|
||||
}
|
||||
|
|
@ -571,7 +528,7 @@ impl Builder<'_> {
|
|||
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, ..Default::default() },
|
||||
blocks,
|
||||
controls: Default::default(),
|
||||
});
|
||||
|
|
@ -591,13 +548,7 @@ fn finite_or(v: f32, d: f32) -> f32 {
|
|||
|
||||
/// Build a document from a flow.
|
||||
pub fn to_doc(flow: &Flow) -> Document {
|
||||
to_doc_in(flow, wordcraft_doc::SectionProps::default())
|
||||
}
|
||||
|
||||
/// [`to_doc`] on a page of the source's own size and margins (`section`), which tables size to.
|
||||
pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.last_section = section;
|
||||
doc.numbering = Default::default();
|
||||
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
|
||||
if uses_code {
|
||||
|
|
@ -616,16 +567,6 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document
|
|||
doc
|
||||
}
|
||||
|
||||
/// `blocks` as document blocks of `doc` (their pictures go into its media), for a header,
|
||||
/// footer or other part an importer adds itself.
|
||||
pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks {
|
||||
let mut out = Builder { doc }.blocks(blocks, 0);
|
||||
if out.is_empty() {
|
||||
out.push(para_block(Paragraph::new()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn uses_kind(blocks: &[FBlock], k: Kind, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& blocks.iter().any(|b| match b {
|
||||
|
|
@ -733,15 +674,7 @@ fn media_ext(key: &str, data: &[u8]) -> String {
|
|||
pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
let (kind, list) = para_kind(doc, p);
|
||||
let mut done: Vec<Para> = Vec::new();
|
||||
let mut out = Para {
|
||||
kind,
|
||||
list,
|
||||
align: p.props.align,
|
||||
page_break: p.props.page_break_before.unwrap_or(false),
|
||||
inlines: Vec::new(),
|
||||
spacing: Spacing::default(),
|
||||
borders: None,
|
||||
};
|
||||
let mut out = Para { kind, list, align: p.props.align, page_break: p.props.page_break_before.unwrap_or(false), inlines: Vec::new() };
|
||||
let in_code = kind == Kind::Code;
|
||||
let mut k = 0usize;
|
||||
for (range, props) in p.run_ranges() {
|
||||
|
|
@ -753,15 +686,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
|||
if c == PAGE_BREAK {
|
||||
if !hidden {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
let next = Para {
|
||||
kind,
|
||||
list: None,
|
||||
align: p.props.align,
|
||||
page_break: true,
|
||||
inlines: Vec::new(),
|
||||
spacing: Spacing::default(),
|
||||
borders: None,
|
||||
};
|
||||
let next = Para { kind, list: None, align: p.props.align, page_break: true, inlines: Vec::new() };
|
||||
let prev = std::mem::replace(&mut out, next);
|
||||
if prev.inlines.is_empty() {
|
||||
out.page_break |= prev.page_break;
|
||||
|
|
@ -871,7 +796,6 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable {
|
|||
covered: c.props.vmerge == VMerge::Continue,
|
||||
header,
|
||||
shading: c.props.shading,
|
||||
borders: c.props.borders,
|
||||
});
|
||||
}
|
||||
rows.push(cells);
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@
|
|||
//!
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`) or the
|
||||
//! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when
|
||||
//! none is named), the formatting words above, stylesheet-based
|
||||
//! fallback skipping, `\'hh` (Windows-1252), the formatting words above, stylesheet-based
|
||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
|
||||
|
|
@ -20,6 +18,7 @@ use std::collections::HashMap;
|
|||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img};
|
||||
use crate::txt::cp1252;
|
||||
|
||||
/// Deepest group nesting we track; deeper groups share the innermost state.
|
||||
const MAX_GROUPS: usize = 512;
|
||||
|
|
@ -482,37 +481,6 @@ enum Dest {
|
|||
Bookmark,
|
||||
}
|
||||
|
||||
/// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages
|
||||
/// (`\headerf` first, `\headerl` left = even, `\header`/`\headerr` the others).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct HeaderFooter {
|
||||
footer: bool,
|
||||
pages: HfPages,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum HfPages {
|
||||
Default,
|
||||
First,
|
||||
Even,
|
||||
}
|
||||
|
||||
/// The reader's output so far, set aside while a header or footer is read.
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
body: Vec<FBlock>,
|
||||
para: Para,
|
||||
para_has: bool,
|
||||
table: Vec<Vec<Cell>>,
|
||||
row: Vec<Cell>,
|
||||
cell: Vec<FBlock>,
|
||||
defs: Vec<CellDef>,
|
||||
def_cur: CellDef,
|
||||
row_header: bool,
|
||||
rule_pending: bool,
|
||||
table_rights: Vec<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct St {
|
||||
dest: Dest,
|
||||
|
|
@ -528,21 +496,6 @@ struct St {
|
|||
outline: Option<u8>,
|
||||
pagebb: bool,
|
||||
field: Option<usize>,
|
||||
/// The code page of the current font's charset (`\fcharset`), when it names one.
|
||||
cp: Option<u16>,
|
||||
/// Space before/after (`\sb`, `\sa`), line spacing (`\sl`, `\slmult`) and indents
|
||||
/// (`\li`, `\ri`, `\fi`), twips.
|
||||
/// The paragraph's borders (`\brdrt`, `\brdrb`, `\brdrl`, `\brdrr`) and the side being
|
||||
/// described.
|
||||
borders: wordcraft_doc::props::Borders,
|
||||
border_side: Option<char>,
|
||||
sb: Option<i32>,
|
||||
sa: Option<i32>,
|
||||
sl: i32,
|
||||
slmult: bool,
|
||||
li: Option<i32>,
|
||||
ri: Option<i32>,
|
||||
fi: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for St {
|
||||
|
|
@ -560,16 +513,6 @@ impl Default for St {
|
|||
outline: None,
|
||||
pagebb: false,
|
||||
field: None,
|
||||
cp: None,
|
||||
borders: Default::default(),
|
||||
border_side: None,
|
||||
sb: None,
|
||||
sa: None,
|
||||
sl: 0,
|
||||
slmult: false,
|
||||
li: None,
|
||||
ri: None,
|
||||
fi: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -580,63 +523,6 @@ struct CellDef {
|
|||
hmerge: u8,
|
||||
shading: Option<Rgb>,
|
||||
right: i32,
|
||||
/// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw`
|
||||
/// width and `\brdrcf` colour).
|
||||
borders: wordcraft_doc::props::Borders,
|
||||
}
|
||||
|
||||
/// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct PageGeom {
|
||||
w: Option<i32>,
|
||||
h: Option<i32>,
|
||||
left: Option<i32>,
|
||||
right: Option<i32>,
|
||||
top: Option<i32>,
|
||||
bottom: Option<i32>,
|
||||
header: Option<i32>,
|
||||
footer: Option<i32>,
|
||||
landscape: bool,
|
||||
}
|
||||
|
||||
impl PageGeom {
|
||||
/// The section properties these give, Word's defaults where the file names none.
|
||||
fn section(&self) -> wordcraft_doc::SectionProps {
|
||||
let mut s = wordcraft_doc::SectionProps::default();
|
||||
// Twips to points, ignoring nonsense (zero, negative, or beyond Word's 22").
|
||||
let pt = |v: Option<i32>, lo: f32, hi: f32| v.map(|t| t as f32 / 20.0).filter(|v| v.is_finite() && (lo..=hi).contains(v));
|
||||
if let Some(w) = pt(self.w, 72.0, 1584.0) {
|
||||
s.page_w = w;
|
||||
}
|
||||
if let Some(h) = pt(self.h, 72.0, 1584.0) {
|
||||
s.page_h = h;
|
||||
}
|
||||
if self.landscape && s.page_w < s.page_h {
|
||||
std::mem::swap(&mut s.page_w, &mut s.page_h);
|
||||
}
|
||||
s.landscape = s.page_w > s.page_h;
|
||||
for (v, f) in [
|
||||
(self.left, &mut s.margin_left),
|
||||
(self.right, &mut s.margin_right),
|
||||
(self.top, &mut s.margin_top),
|
||||
(self.bottom, &mut s.margin_bottom),
|
||||
(self.header, &mut s.header),
|
||||
(self.footer, &mut s.footer),
|
||||
] {
|
||||
if let Some(v) = pt(v, 0.0, 720.0) {
|
||||
*f = v;
|
||||
}
|
||||
}
|
||||
// Margins that leave no room for text fall back to Word's.
|
||||
let d = wordcraft_doc::SectionProps::default();
|
||||
if s.page_w - s.margin_left - s.margin_right < 72.0 {
|
||||
(s.margin_left, s.margin_right) = (d.margin_left, d.margin_right);
|
||||
}
|
||||
if s.page_h - s.margin_top - s.margin_bottom < 72.0 {
|
||||
(s.margin_top, s.margin_bottom) = (d.margin_top, d.margin_bottom);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -647,26 +533,6 @@ struct Reader {
|
|||
fonts: HashMap<i32, String>,
|
||||
font_cur: i32,
|
||||
font_name: String,
|
||||
/// Code pages of the fonts whose charset names one (`\fcharset204` → 1251).
|
||||
font_cps: HashMap<i32, u16>,
|
||||
font_cp_cur: Option<u16>,
|
||||
/// The document's code page (`\ansicpg`); 0 until it names one (Windows-1252 then).
|
||||
ansicpg: u16,
|
||||
/// The default font (`\deff`), whose charset applies before any `\f`.
|
||||
deff: i32,
|
||||
page: PageGeom,
|
||||
/// The header or footer being read: the group depth it started at, which one, and the
|
||||
/// body's output, put back when the group closes.
|
||||
hf: Option<(usize, HeaderFooter, Box<Output>)>,
|
||||
/// Headers and footers read, in order.
|
||||
hf_parts: Vec<(HeaderFooter, Vec<FBlock>)>,
|
||||
/// `\titlepg`: the first page has its own header and footer.
|
||||
title_page: bool,
|
||||
/// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width
|
||||
/// and colour follow.
|
||||
cell_side: Option<char>,
|
||||
/// Each table row's cell right edges (`\cellx`, twips), for the table's column grid.
|
||||
table_rights: Vec<Vec<i32>>,
|
||||
colors: Vec<Option<Rgb>>,
|
||||
color_cur: (u8, u8, u8, bool),
|
||||
styles: HashMap<i32, String>,
|
||||
|
|
@ -715,60 +581,6 @@ impl Reader {
|
|||
self.stack.len()
|
||||
}
|
||||
|
||||
/// Set the output aside (a header or footer starts) or put it back (it ended), returning the
|
||||
/// other one.
|
||||
fn swap_output(&mut self, mut o: Output) -> Output {
|
||||
std::mem::swap(&mut o.body, &mut self.body);
|
||||
std::mem::swap(&mut o.para, &mut self.para);
|
||||
std::mem::swap(&mut o.para_has, &mut self.para_has);
|
||||
std::mem::swap(&mut o.table, &mut self.table);
|
||||
std::mem::swap(&mut o.row, &mut self.row);
|
||||
std::mem::swap(&mut o.cell, &mut self.cell);
|
||||
std::mem::swap(&mut o.defs, &mut self.defs);
|
||||
std::mem::swap(&mut o.def_cur, &mut self.def_cur);
|
||||
std::mem::swap(&mut o.row_header, &mut self.row_header);
|
||||
std::mem::swap(&mut o.rule_pending, &mut self.rule_pending);
|
||||
std::mem::swap(&mut o.table_rights, &mut self.table_rights);
|
||||
o
|
||||
}
|
||||
|
||||
/// The border of the cell side being described, else of the paragraph side.
|
||||
fn cell_border_slot(&mut self) -> Option<&mut Option<wordcraft_doc::props::Border>> {
|
||||
let (b, side) = match self.cell_side {
|
||||
Some(c) => (&mut self.def_cur.borders, c),
|
||||
None => (&mut self.st.borders, self.st.border_side?),
|
||||
};
|
||||
match side {
|
||||
't' => Some(&mut b.top),
|
||||
'l' => Some(&mut b.left),
|
||||
'b' => Some(&mut b.bottom),
|
||||
'r' => Some(&mut b.right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A header or footer group starts: read it into an output of its own.
|
||||
fn start_header_footer(&mut self, which: HeaderFooter) {
|
||||
let body = self.swap_output(Output::default());
|
||||
self.hf = Some((self.depth(), which, Box::new(body)));
|
||||
self.st.intbl = false;
|
||||
}
|
||||
|
||||
/// The header or footer group closed: keep what it held and go back to the body.
|
||||
fn end_header_footer(&mut self) {
|
||||
let Some((_, which, body)) = self.hf.take() else { return };
|
||||
if self.para_has || !self.para.inlines.is_empty() {
|
||||
self.end_para(false);
|
||||
}
|
||||
self.close_table();
|
||||
let part = self.swap_output(*body);
|
||||
// A document names each one once; a repeat (another section's) replaces it.
|
||||
self.hf_parts.retain(|(w, _)| *w != which);
|
||||
if self.hf_parts.len() < 16 {
|
||||
self.hf_parts.push((which, part.body));
|
||||
}
|
||||
}
|
||||
|
||||
fn open(&mut self) {
|
||||
self.skip_chars = 0;
|
||||
if self.stack.len() >= MAX_GROUPS {
|
||||
|
|
@ -819,28 +631,15 @@ impl Reader {
|
|||
}
|
||||
}
|
||||
self.st = prev;
|
||||
if self.hf.as_ref().is_some_and(|(d, _, _)| self.depth() < *d) {
|
||||
self.end_header_footer();
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if !name.is_empty() && self.fonts.len() < 4096 {
|
||||
self.fonts.insert(self.font_cur, name);
|
||||
if let Some(cp) = self.font_cp_cur.take() {
|
||||
self.font_cps.insert(self.font_cur, cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of 8-bit text (`\'hh` or raw) as a char, in the current font's code page, else the
|
||||
/// default font's, else the document's.
|
||||
fn byte_char(&self, b: u8) -> char {
|
||||
let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg });
|
||||
wordcraft_doc::encoding::windows_byte(cp, b)
|
||||
}
|
||||
|
||||
fn commit_style(&mut self) {
|
||||
let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if self.styles.len() < 4096 {
|
||||
|
|
@ -980,8 +779,6 @@ impl Reader {
|
|||
self.rule_pending = false;
|
||||
p.align = self.st.align;
|
||||
p.page_break = self.st.pagebb;
|
||||
p.spacing = self.spacing();
|
||||
p.borders = self.st.borders.any_visible().then_some(self.st.borders);
|
||||
if let Some(ls) = self.st.ls {
|
||||
let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id));
|
||||
let lv = self.st.ilvl.min(8);
|
||||
|
|
@ -1019,32 +816,6 @@ impl Reader {
|
|||
}
|
||||
}
|
||||
|
||||
/// The current paragraph's spacing and indents in points.
|
||||
fn spacing(&self) -> model::Spacing {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let pt = |v: i32| (v as f32 / 20.0).clamp(-1584.0, 1584.0);
|
||||
let gap = |v: Option<i32>| v.map(|v| pt(v).max(0.0));
|
||||
let sl = self.st.sl;
|
||||
let line = if sl == 0 {
|
||||
None
|
||||
} else if sl < 0 {
|
||||
Some(LineSpacing::Exactly(pt(sl.saturating_neg()).max(1.0)))
|
||||
} else if self.st.slmult {
|
||||
// \sl240\slmult1 is single spacing.
|
||||
Some(LineSpacing::Multiple((sl as f32 / 240.0).clamp(0.06, 132.0)))
|
||||
} else {
|
||||
Some(LineSpacing::AtLeast(pt(sl)))
|
||||
};
|
||||
model::Spacing {
|
||||
before: gap(self.st.sb),
|
||||
after: gap(self.st.sa),
|
||||
line,
|
||||
left: self.st.li.map(pt),
|
||||
right: self.st.ri.map(pt),
|
||||
first: self.st.fi.map(pt),
|
||||
}
|
||||
}
|
||||
|
||||
fn end_cell(&mut self) {
|
||||
self.end_para(true);
|
||||
let blocks = std::mem::take(&mut self.cell);
|
||||
|
|
@ -1060,19 +831,9 @@ impl Reader {
|
|||
let mut cells = std::mem::take(&mut self.row);
|
||||
let defs = self.defs.clone();
|
||||
let mut out: Vec<Cell> = Vec::new();
|
||||
let mut rights: Vec<i32> = Vec::new();
|
||||
for (i, mut c) in cells.drain(..).enumerate() {
|
||||
let d = defs.get(i).cloned().unwrap_or_default();
|
||||
// A merged-in cell widens the cell it joins.
|
||||
if d.hmerge == 2
|
||||
&& let Some(last) = rights.last_mut()
|
||||
{
|
||||
*last = d.right;
|
||||
} else {
|
||||
rights.push(d.right);
|
||||
}
|
||||
c.shading = d.shading;
|
||||
c.borders = d.borders.any_visible().then_some(d.borders);
|
||||
c.header = self.row_header;
|
||||
match d.vmerge {
|
||||
1 => c.rowspan = 2,
|
||||
|
|
@ -1088,9 +849,7 @@ impl Reader {
|
|||
out.push(c);
|
||||
}
|
||||
if !out.is_empty() && self.table.len() < wordcraft_doc::table::MAX_ROWS {
|
||||
rights.truncate(out.len());
|
||||
self.table.push(out);
|
||||
self.table_rights.push(rights);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1103,11 +862,8 @@ impl Reader {
|
|||
self.end_row();
|
||||
}
|
||||
if !self.table.is_empty() {
|
||||
let mut rows = std::mem::take(&mut self.table);
|
||||
let rights = std::mem::take(&mut self.table_rights);
|
||||
let widths = column_grid(&mut rows, &rights);
|
||||
// Word draws only the borders the cells name: no grid style, each cell its own.
|
||||
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true }));
|
||||
let rows = std::mem::take(&mut self.table);
|
||||
self.body.push(FBlock::Table(FTable { rows, widths: Vec::new(), borderless: false }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1147,18 +903,6 @@ impl Reader {
|
|||
"shppict" => return,
|
||||
"pict" => Some(Dest::Pict),
|
||||
"bkmkstart" => Some(Dest::Bookmark),
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf"
|
||||
if self.hf.is_none() && self.st.dest == Dest::Body && !self.st.intbl =>
|
||||
{
|
||||
let footer = w.starts_with("footer");
|
||||
let pages = match w.strip_prefix(if footer { "footer" } else { "header" }) {
|
||||
Some("f") => HfPages::First,
|
||||
Some("l") => HfPages::Even,
|
||||
_ => HfPages::Default,
|
||||
};
|
||||
self.start_header_footer(HeaderFooter { footer, pages });
|
||||
return;
|
||||
}
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
||||
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
||||
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
||||
|
|
@ -1218,16 +962,9 @@ impl Reader {
|
|||
return;
|
||||
}
|
||||
Dest::FontTbl => {
|
||||
match w {
|
||||
"f" => {
|
||||
self.font_cur = pv;
|
||||
self.font_name.clear();
|
||||
self.font_cp_cur = None;
|
||||
}
|
||||
"fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv),
|
||||
// A font's explicit code page (`\cpg1251`) wins over its charset.
|
||||
"cpg" if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) => self.font_cp_cur = Some(pv as u16),
|
||||
_ => {}
|
||||
if w == "f" {
|
||||
self.font_cur = pv;
|
||||
self.font_name.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1378,14 +1115,7 @@ impl Reader {
|
|||
self.st.fmt.sub = false;
|
||||
}
|
||||
"fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None },
|
||||
"ansicpg" => {
|
||||
if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) {
|
||||
self.ansicpg = pv as u16;
|
||||
}
|
||||
}
|
||||
"deff" => self.deff = pv,
|
||||
"f" => {
|
||||
self.st.cp = self.font_cps.get(&pv).copied();
|
||||
let name = self.fonts.get(&pv).cloned();
|
||||
match name {
|
||||
Some(n) if model::is_mono(&n) => {
|
||||
|
|
@ -1412,12 +1142,7 @@ impl Reader {
|
|||
"intbl" => self.st.intbl = true,
|
||||
"ls" => self.st.ls = Some(pv),
|
||||
"ilvl" => self.st.ilvl = pv.clamp(0, 8) as u8,
|
||||
"brdrb" | "brdrt" | "brdrl" | "brdrr" if self.cell_side.is_none() => {
|
||||
if w == "brdrb" {
|
||||
self.rule_pending = true;
|
||||
}
|
||||
self.st.border_side = w.chars().last();
|
||||
}
|
||||
"brdrb" => self.rule_pending = true,
|
||||
"trowd" => {
|
||||
self.defs.clear();
|
||||
self.def_cur = CellDef::default();
|
||||
|
|
@ -1429,63 +1154,7 @@ impl Reader {
|
|||
"clmgf" => self.def_cur.hmerge = 1,
|
||||
"clmrg" => self.def_cur.hmerge = 2,
|
||||
"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),
|
||||
"sl" => self.st.sl = pv,
|
||||
"slmult" => self.st.slmult = on,
|
||||
"li" | "lin" => self.st.li = Some(pv),
|
||||
"ri" | "rin" => self.st.ri = Some(pv),
|
||||
"fi" => self.st.fi = Some(pv),
|
||||
"clbrdrt" => self.cell_side = Some('t'),
|
||||
"clbrdrl" => self.cell_side = Some('l'),
|
||||
"clbrdrb" => self.cell_side = Some('b'),
|
||||
"clbrdrr" => self.cell_side = Some('r'),
|
||||
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
|
||||
| "brdrtbl"
|
||||
if self.cell_side.is_some() || self.st.border_side.is_some() =>
|
||||
{
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let style = match w {
|
||||
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
|
||||
"brdrth" => BorderStyle::Thick,
|
||||
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrdot" => BorderStyle::Dotted,
|
||||
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
|
||||
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
|
||||
"brdrtriple" => BorderStyle::Triple,
|
||||
"brdrwavy" => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
};
|
||||
let b = (style != BorderStyle::None).then_some(Border { style, ..Border::single(0.5) });
|
||||
if let Some(slot) = self.cell_border_slot() {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
"brdrw" if self.cell_side.is_some() || self.st.border_side.is_some() => {
|
||||
let width = (pv as f32 / 20.0).clamp(0.25, 6.0);
|
||||
if let Some(Some(b)) = self.cell_border_slot() {
|
||||
b.width = width;
|
||||
}
|
||||
}
|
||||
"brdrcf" if self.cell_side.is_some() || self.st.border_side.is_some() => {
|
||||
let color = self.colors.get(pv.max(0) as usize).copied().flatten();
|
||||
if let Some(Some(b)) = self.cell_border_slot() {
|
||||
b.color = color;
|
||||
}
|
||||
}
|
||||
"titlepg" => self.title_page = on,
|
||||
"paperw" | "pgwsxn" => self.page.w = Some(pv),
|
||||
"paperh" | "pghsxn" => self.page.h = Some(pv),
|
||||
"margl" | "marglsxn" => self.page.left = Some(pv),
|
||||
"margr" | "margrsxn" => self.page.right = Some(pv),
|
||||
"margt" | "margtsxn" => self.page.top = Some(pv),
|
||||
"margb" | "margbsxn" => self.page.bottom = Some(pv),
|
||||
"headery" => self.page.header = Some(pv),
|
||||
"footery" => self.page.footer = Some(pv),
|
||||
"landscape" | "lndscpsxn" => self.page.landscape = on,
|
||||
"cellx" => {
|
||||
self.cell_side = None;
|
||||
let mut d = std::mem::take(&mut self.def_cur);
|
||||
d.right = pv;
|
||||
if self.defs.len() < 63 {
|
||||
|
|
@ -1537,25 +1206,6 @@ fn hyperlink(instr: &str) -> Option<String> {
|
|||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
parse_with_page(bytes).map(|(f, _, _)| f)
|
||||
}
|
||||
|
||||
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
||||
/// is the font of all text that doesn't select one.
|
||||
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
||||
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
||||
}
|
||||
|
||||
/// Everything an import takes from an RTF file.
|
||||
struct Parsed {
|
||||
flow: Flow,
|
||||
page: PageGeom,
|
||||
default_font: Option<String>,
|
||||
headers_footers: Vec<(HeaderFooter, Vec<FBlock>)>,
|
||||
title_page: bool,
|
||||
}
|
||||
|
||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
||||
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
||||
|
|
@ -1650,8 +1300,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||
if r.st.dest == Dest::Pict {
|
||||
r.text(&format!("{v:02x}"));
|
||||
} else {
|
||||
let c = r.byte_char(v);
|
||||
r.ch(c);
|
||||
r.ch(cp1252(v));
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
|
|
@ -1701,8 +1350,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||
}
|
||||
} else {
|
||||
for b in chunk {
|
||||
let c = r.byte_char(*b);
|
||||
r.ch(c);
|
||||
r.ch(cp1252(*b));
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
|
|
@ -1710,8 +1358,6 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// A header or footer left open by a truncated file ends here.
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
}
|
||||
|
|
@ -1725,72 +1371,12 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned();
|
||||
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page })
|
||||
}
|
||||
|
||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||
/// column, and each cell spans the grid columns between its left and right edges, as Word lays
|
||||
/// out RTF rows whose cells don't line up. Returns the widths in points, or none (equal columns)
|
||||
/// when the edges don't make a grid.
|
||||
fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
|
||||
let mut edges: Vec<i32> = rights.iter().flatten().copied().filter(|&x| x > 0).collect();
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
let ok = rights.len() == rows.len()
|
||||
&& rows
|
||||
.iter()
|
||||
.zip(rights)
|
||||
.all(|(r, x)| r.len() == x.len() && x.windows(2).all(|w| w.first() < w.get(1)) && x.first().is_some_and(|&v| v > 0))
|
||||
&& !edges.is_empty()
|
||||
&& edges.len() <= 63;
|
||||
if !ok {
|
||||
return Vec::new();
|
||||
}
|
||||
for (row, x) in rows.iter_mut().zip(rights) {
|
||||
let mut left = 0;
|
||||
for (c, &right) in row.iter_mut().zip(x) {
|
||||
let span = edges.iter().filter(|&&e| e > left && e <= right).count().max(1);
|
||||
c.colspan = span as u32;
|
||||
left = right;
|
||||
}
|
||||
}
|
||||
let mut prev = 0;
|
||||
edges
|
||||
.iter()
|
||||
.map(|&e| {
|
||||
let w = (e - prev) as f32 / 20.0;
|
||||
prev = e;
|
||||
w
|
||||
})
|
||||
.collect()
|
||||
Ok(Flow { blocks, meta: r.meta })
|
||||
}
|
||||
|
||||
/// Parse RTF bytes into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?;
|
||||
let mut doc = model::to_doc_in(&flow, page.section());
|
||||
for (which, blocks) in &headers_footers {
|
||||
let blocks = model::part_blocks(&mut doc, blocks);
|
||||
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
|
||||
let set = if which.footer { &mut doc.last_section.footers } else { &mut doc.last_section.headers };
|
||||
match which.pages {
|
||||
HfPages::Default => set.default = Some(id),
|
||||
HfPages::First => set.first = Some(id),
|
||||
HfPages::Even => set.even = Some(id),
|
||||
}
|
||||
}
|
||||
doc.last_section.title_page = title_page;
|
||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||
// wants beyond that it states on each paragraph.
|
||||
let dp = &mut doc.styles.default_para;
|
||||
dp.space_before = Some(0.0);
|
||||
dp.space_after = Some(0.0);
|
||||
dp.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
|
||||
if default_font.is_some() {
|
||||
doc.styles.default_chr.font = default_font;
|
||||
}
|
||||
Ok(doc)
|
||||
Ok(model::to_doc(&parse(bytes)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1834,193 +1420,6 @@ mod tests {
|
|||
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_borders() {
|
||||
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
|
||||
let rtf = br"{\rtf1\ansi{\colortbl;\red0\green0\blue0;\red216\green216\blue216;}
|
||||
\pard\brdrb\brdrs\brdrw10\brdrcf2 Section\par
|
||||
\pard Body\par
|
||||
\pard\brdrt\brdrdb\brdrl\brdrs\brdrr\brdrs Boxed\par
|
||||
\pard\brdrb\brdrs\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let paras: Vec<&Para> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
let b = paras[0].borders.unwrap();
|
||||
let bottom = b.bottom.unwrap();
|
||||
assert_eq!((bottom.width, bottom.color), (0.5, Some(Rgb(216, 216, 216))));
|
||||
assert!(b.top.is_none());
|
||||
assert_eq!(paras[0].kind, Kind::Normal);
|
||||
// \pard resets them.
|
||||
assert!(paras[1].borders.is_none());
|
||||
let b = paras[2].borders.unwrap();
|
||||
assert_eq!(b.top.map(|x| x.style), Some(wordcraft_doc::props::BorderStyle::Double));
|
||||
assert!(b.left.is_some() && b.right.is_some() && b.bottom.is_none());
|
||||
// An empty paragraph with only a bottom border is still a rule.
|
||||
assert_eq!(paras[3].kind, Kind::Rule);
|
||||
// The document's paragraph carries them.
|
||||
let d = import(rtf).unwrap();
|
||||
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
|
||||
assert!(first.props.borders.and_then(|b| b.bottom).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_footers() {
|
||||
let rtf = br"{\rtf1\ansi\titlepg
|
||||
{\header \pard\qr Running head\par}
|
||||
{\headerf \pard First head {\b bold}\par}
|
||||
{\footer \pard Page \chpgn\par}
|
||||
\pard Body text\par
|
||||
\trowd\cellx2000\pard\intbl cell\cell\row
|
||||
\pard after\par}";
|
||||
let d = import(rtf).unwrap();
|
||||
let s = &d.last_section;
|
||||
assert!(s.title_page);
|
||||
let text = |id: Option<u32>| {
|
||||
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
|
||||
p.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
};
|
||||
assert_eq!(text(s.headers.default), "Running head");
|
||||
assert_eq!(text(s.headers.first), "First head bold");
|
||||
assert!(text(s.footers.default).starts_with("Page"));
|
||||
assert!(s.footers.first.is_none() && s.headers.even.is_none());
|
||||
// The body keeps only its own text, and its table survives the header groups.
|
||||
let f = parse(rtf).unwrap();
|
||||
let t = texts(&f);
|
||||
assert_eq!(t, ["Normal:Body text", "table1x1:cell", "Normal:after"]);
|
||||
// Without \titlepg there is no separate first page.
|
||||
let d = import(br"{\rtf1\ansi{\headerf x\par} y\par}").unwrap();
|
||||
assert!(!d.last_section.title_page && d.last_section.headers.first.is_some());
|
||||
// An unterminated header still ends, and the body text after it isn't lost.
|
||||
assert!(import(br"{\rtf1\ansi{\header x").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spacing_and_indents() {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\pard\sb120\sa150\sl360\slmult1\li720\ri360\fi-360 a\par
|
||||
\pard\sl-280 b\par
|
||||
\pard\sl300\slmult0 c\par
|
||||
\pard d\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let sp: Vec<model::Spacing> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.spacing) } else { None }).collect();
|
||||
assert_eq!(sp[0].before, Some(6.0));
|
||||
assert_eq!(sp[0].after, Some(7.5));
|
||||
assert_eq!(sp[0].line, Some(LineSpacing::Multiple(1.5)));
|
||||
assert_eq!((sp[0].left, sp[0].right, sp[0].first), (Some(36.0), Some(18.0), Some(-18.0)));
|
||||
assert_eq!(sp[1].line, Some(LineSpacing::Exactly(14.0)));
|
||||
assert_eq!(sp[2].line, Some(LineSpacing::AtLeast(15.0)));
|
||||
// \pard resets them.
|
||||
assert_eq!(sp[3], model::Spacing::default());
|
||||
// The document's defaults are RTF's: single, no space after; the paragraph's own win.
|
||||
let d = import(rtf).unwrap();
|
||||
assert_eq!(d.styles.default_para.space_after, Some(0.0));
|
||||
assert_eq!(d.styles.default_para.line_spacing, Some(LineSpacing::Multiple(1.0)));
|
||||
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
|
||||
assert_eq!(first.props.space_after, Some(7.5));
|
||||
assert_eq!(first.props.indent_first, Some(-18.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_font_is_deff_or_font_zero() {
|
||||
// Text that never selects a font is in the default font: \deff, else font 0.
|
||||
let d = import(br"{\rtf1\ansi{\fonttbl{\f0\fnil Times New Roman Cyr;}{\f1 Arial;}}\plain x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Times New Roman Cyr"));
|
||||
let d = import(br"{\rtf1\ansi\deff1{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}} x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Arial"));
|
||||
// No font table: our default stays.
|
||||
let d = import(br"{\rtf1\ansi x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font, wordcraft_doc::StyleSheet::builtin().default_chr.font);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_and_margins() {
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr850\margt1134\margb1134 x\par}").unwrap();
|
||||
let s = &d.last_section;
|
||||
assert!((s.page_w - 595.3).abs() < 0.1 && (s.page_h - 841.9).abs() < 0.1, "{} x {}", s.page_w, s.page_h);
|
||||
assert!((s.margin_left - 56.7).abs() < 0.1 && (s.margin_right - 42.5).abs() < 0.1);
|
||||
assert!(!s.landscape);
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\landscape x\par}").unwrap();
|
||||
assert!(d.last_section.landscape && d.last_section.page_w > d.last_section.page_h);
|
||||
// Nonsense keeps Word's defaults.
|
||||
let d = import(br"{\rtf1\ansi\paperw0\margl99999 x\par}").unwrap();
|
||||
assert_eq!(d.last_section, wordcraft_doc::SectionProps::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_grid_from_cell_edges_and_borders() {
|
||||
// Rows whose cells don't line up share one grid: edges 2000, 3000, 9000.
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\trowd\cellx2000\cellx9000 \pard\intbl a\cell b\cell\row
|
||||
\trowd\cellx3000\cellx9000 \pard\intbl c\cell d\cell\row
|
||||
\pard after\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(t.widths, [100.0, 50.0, 300.0]);
|
||||
assert_eq!(t.rows[0].iter().map(|c| c.colspan).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(t.rows[1].iter().map(|c| c.colspan).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!(t.cols(), 3);
|
||||
// RTF tables have no grid style: each cell draws the borders it names, nothing else.
|
||||
assert!(t.borderless);
|
||||
assert!(t.rows.iter().flatten().all(|c| c.borders.is_none()));
|
||||
// A rule under a cell (a letterhead's line): the bottom side only, its width and colour.
|
||||
let f =
|
||||
parse(br"{\rtf1\ansi{\colortbl;\red174\green174\blue174;}\trowd\clbrdrb\brdrs\brdrw15\brdrcf1\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
let b = t.rows[0][0].borders.unwrap();
|
||||
assert!(b.top.is_none() && b.left.is_none() && b.right.is_none());
|
||||
let bottom = b.bottom.unwrap();
|
||||
assert_eq!((bottom.style, bottom.width, bottom.color), (wordcraft_doc::props::BorderStyle::Single, 0.75, Some(Rgb(174, 174, 174))));
|
||||
// Every side, in different styles, per cell.
|
||||
let f = parse(br"{\rtf1\ansi\trowd\clbrdrt\brdrdb\clbrdrl\brdrs\brdrw10\clbrdrb\brdrdot\clbrdrr\brdrth\cellx2000\cellx4000\pard\intbl a\cell b\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
let b = t.rows[0][0].borders.unwrap();
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
assert_eq!(
|
||||
[b.top.map(|x| x.style), b.left.map(|x| x.style), b.bottom.map(|x| x.style), b.right.map(|x| x.style)],
|
||||
[Some(S::Double), Some(S::Single), Some(S::Dotted), Some(S::Thick)]
|
||||
);
|
||||
assert_eq!(b.left.map(|x| x.width), Some(0.5));
|
||||
assert!(t.rows[0][1].borders.is_none(), "the second cell names no borders");
|
||||
// A border side set to none isn't a border.
|
||||
let f = parse(br"{\rtf1\ansi\trowd\clbrdrl\brdrnone\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert!(t.rows[0][0].borders.is_none());
|
||||
// The document's cells carry them.
|
||||
let d = import(br"{\rtf1\ansi\trowd\clbrdrb\brdrs\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
|
||||
assert!(t.props.style.is_none());
|
||||
assert!(t.rows[0].cells[0].props.borders.and_then(|b| b.bottom).is_some());
|
||||
// The table sizes to the document's page, not Letter.
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\margl1134\margr1134\trowd\cellx4818\cellx9638\pard\intbl a\cell b\cell\row}").unwrap();
|
||||
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
|
||||
assert_eq!(t.grid, [240.9, 241.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_pages_from_ansicpg_and_font_charsets() {
|
||||
// The document's code page: Russian RTF as accounting systems write it.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg1251\deff0{\fonttbl{\f0 Times New Roman;}}\f0 \'cf\'f0\'e8\'e2\'e5\'f2, \'b8\'eb\'ea\'e0 \'b9 5\par}")
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Привет, ёлка № 5"]);
|
||||
// Font charsets switch inside a Windows-1252 document: Western, Cyrillic, Central
|
||||
// European, then back to the first font.
|
||||
let f = parse(
|
||||
br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset204 Arial CYR;}{\f2\fcharset238 Arial CE;}}\f0 caf\'e9 {\f1 \'c4\'e0} \f2 \'a3\'f3d\'9f \f0 na\'efve\par}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:café Да Łódź naïve"]);
|
||||
// The default font's charset applies before any \f; an explicit \cpg wins over \fcharset.
|
||||
let f = parse(br"{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Times;}{\f2\fcharset0\cpg1253 Greek;}}\'cc\'e8\'f0 \f2 \'c1\'e2\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Мир Αβ"]);
|
||||
// Raw 8-bit text (not escaped) follows the same code page.
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg1251 \xc4\xee\xec\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Дом"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String> {
|
||||
let pixels = image::RgbaImage::from_pixel(1, 1, image::Rgba([40, 80, 160, 255]));
|
||||
|
|
|
|||
|
|
@ -280,14 +280,6 @@ fn txt_round_trip() {
|
|||
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_export_writes_symbol_font_text_as_unicode() {
|
||||
let mut d = Document::from_text("Done J\u{F0FC}");
|
||||
d.format_range(&wordcraft_doc::Pos::body(0, 5), &wordcraft_doc::Pos::body(0, 9), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
let out = export("txt", &d).unwrap().unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "Done \u{263A}\u{2713}");
|
||||
}
|
||||
|
||||
/// #569: a file with an extension we don't know opens as text when it is text (a playlist), and
|
||||
/// a binary file is told apart.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ fn blocks_text(blocks: &[FBlock], out: &mut Vec<String>, lists: &mut ListCounter
|
|||
}
|
||||
None => lists.reset(),
|
||||
}
|
||||
line.push_str(&p.shown_text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
|
||||
line.push_str(&p.text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
|
||||
out.push(line);
|
||||
}
|
||||
FBlock::Table(t) => {
|
||||
|
|
|
|||
|
|
@ -720,13 +720,7 @@ fn lines(
|
|||
text.push_str(s);
|
||||
shown = Some(r);
|
||||
} else if let Some(p) = para {
|
||||
let t = p.text.get(c.start..c.end).unwrap_or("");
|
||||
// Symbol-font characters (Wingdings…) read as the Unicode symbols drawn.
|
||||
if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) {
|
||||
text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t));
|
||||
} else {
|
||||
text.push_str(t);
|
||||
}
|
||||
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
|
||||
}
|
||||
let b = text.len();
|
||||
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
|
||||
|
|
|
|||
|
|
@ -213,15 +213,6 @@ fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
|||
Some(Pos { story, path: path.clone(), off: para.off_at_x(li, u) })
|
||||
}
|
||||
|
||||
/// The body line on `page` whose band is nearest height `y` (the leftmost on a tie, so in
|
||||
/// columns the first column wins); only top-level paragraphs' lines when `top_only`.
|
||||
fn nearest_line_at<'a>(page: &'a Page, y: f32, top_only: bool) -> Option<LineHit<'a>> {
|
||||
page_lines(page, StoryRef::Body)
|
||||
.into_iter()
|
||||
.filter(|l| !top_only || l.path.0.len() <= 1)
|
||||
.min_by(|a, b| band_dist(y, a.top, a.bottom - a.top).total_cmp(&band_dist(y, b.top, b.bottom - b.top)).then(a.left.total_cmp(&b.left)))
|
||||
}
|
||||
|
||||
/// How far `v` is outside the band `top..top + h`.
|
||||
fn band_dist(v: f32, top: f32, h: f32) -> f32 {
|
||||
if v < top {
|
||||
|
|
@ -485,51 +476,6 @@ impl DocLayout {
|
|||
None
|
||||
}
|
||||
|
||||
/// Word's selection bar (#856): when (x, y) is in the page's left margin, left of the body
|
||||
/// text column, the start of the body line at that height. Not in the header/footer area,
|
||||
/// not beside a table's lines (those are left to the table), nothing on an empty page.
|
||||
pub fn selection_bar_at(&self, page: usize, x: f32, y: f32) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if !(x.is_finite() && y.is_finite()) || x < 0.0 || x >= p.body.x || y < p.body.y || y > p.body.bottom() {
|
||||
return None;
|
||||
}
|
||||
let l = nearest_line_at(p, y, false)?;
|
||||
if l.path.0.len() > 1 || l.left < p.body.x - 0.5 {
|
||||
// A table's line, or text hanging into the margin: not the selection bar.
|
||||
return None;
|
||||
}
|
||||
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
|
||||
}
|
||||
|
||||
/// The start of the top-level body line nearest height `y` on `page` (dragging in the
|
||||
/// selection bar extends line by line, wherever the pointer is across).
|
||||
pub fn body_line_at(&self, page: usize, y: f32) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let l = nearest_line_at(p, y, true)?;
|
||||
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
|
||||
}
|
||||
|
||||
/// The whole visual line holding `pos`, trailing spaces and break included: (start, end,
|
||||
/// whether it is the paragraph's last line). What a click in the selection bar selects.
|
||||
pub fn line_span(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos, bool)> {
|
||||
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, .. } = it else { continue };
|
||||
let li = para.line_of(pos.off);
|
||||
if li < *l0 || li >= *l1 {
|
||||
continue;
|
||||
}
|
||||
let l = para.lines.get(li)?;
|
||||
let next = para.lines.get(li.saturating_add(1));
|
||||
let end = next.map_or(l.stop, |n| n.start).max(l.start);
|
||||
let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };
|
||||
return Some((mk(l.start), mk(end), next.is_none()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Column (block) selection: the piece of every line from the line holding `a` to the line
|
||||
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
|
||||
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use wordcraft_geom::{Point, Rect, Spin};
|
|||
pub use fields::FieldCtx;
|
||||
pub use hit::VisualStep;
|
||||
pub use para::{LineEnd, ParaLayout};
|
||||
pub use table::{autofit_widths, cell_margins, measure_table_columns};
|
||||
pub use table::{autofit_widths, measure_table_columns};
|
||||
|
||||
/// How the document is viewed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -927,9 +927,10 @@ fn layout_box(
|
|||
// space after the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && i == 0 {
|
||||
before = 0.0;
|
||||
} else if i > 0 && before > 0.0 && prev_after > 0.0 {
|
||||
} else if i > 0 && (pl.rp.before_auto || prev_after_auto) {
|
||||
before = (before - prev_after).max(0.0);
|
||||
}
|
||||
// Word: space between paragraphs is before + after (no collapsing).
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
// Floating objects anchored here: place them, then wrap the text around them.
|
||||
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
||||
|
|
@ -963,7 +964,7 @@ fn layout_box(
|
|||
// Automatic space after: none after the last paragraph of a cell or box.
|
||||
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
|
||||
y += pl.height + after;
|
||||
prev_after = after;
|
||||
prev_after = if same && ctxl { 0.0 } else { after };
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
prev_listed = listed;
|
||||
prev_style = Some((pl.rp.style.clone(), ctxl));
|
||||
|
|
@ -1914,28 +1915,18 @@ fn layout_pass(
|
|||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||||
// A page holds text running one way: a section turning the other way starts a page.
|
||||
let s = &doc.settings;
|
||||
let (filler, restart) = section_side(start, restart, pb.number, pb.pages.len(), s.even_odd_headers, s.mirror_margins);
|
||||
// The blank page that puts the section on the right side ends the previous section.
|
||||
if filler
|
||||
&& !web
|
||||
&& let Some((_, prev)) = si.checked_sub(1).and_then(|k| sections.get(k))
|
||||
{
|
||||
pb.sect = prev;
|
||||
pb.sect_idx = si - 1;
|
||||
let (ph, pf) = (linked(§ions, si - 1, |s| s.headers), linked(§ions, si - 1, |s| s.footers));
|
||||
let top = body_top_for(&mut ctx, prev, ph.default);
|
||||
pb.foot_top = foot_top_for(&mut ctx, prev, pf.default);
|
||||
pb.new_page(block, top);
|
||||
pb.sect = sect;
|
||||
pb.sect_idx = si;
|
||||
}
|
||||
if let Some(n) = restart {
|
||||
pb.number = n.saturating_sub(1);
|
||||
}
|
||||
pb.foot_top = first_foot;
|
||||
pb.new_page(block, first_top);
|
||||
pb.foot_top = foot_top;
|
||||
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
||||
let want_even = start == SectionStart::EvenPage;
|
||||
if pb.number.is_multiple_of(2) != want_even {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continuous: new column layout below the current text (its tallest column).
|
||||
pb.cols = sect.column_boxes();
|
||||
|
|
@ -2287,33 +2278,6 @@ fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
|
|||
Some(y + l.top + l.height - f.top)
|
||||
}
|
||||
|
||||
/// Whether a section that starts on a new page needs a blank page first so it lands on the right
|
||||
/// side, and the page number it restarts at. `last` is the number of the page before it and
|
||||
/// `pages` how many pages there are so far.
|
||||
/// - An odd- or even-page break counts on from the previous section's numbers. A section that
|
||||
/// restarts its numbering gets no blank page unless the document prints both sides (different
|
||||
/// odd and even headers, or mirror margins); a restart at the wrong parity takes the next number.
|
||||
/// - With different odd and even headers, a next-page break that restarts the numbers at an odd
|
||||
/// (even) number starts on an odd (even) sheet.
|
||||
///
|
||||
/// (Word's output for docxide-pdf's fixtures: bush_fires_act_comparison has a blank page 2.)
|
||||
fn section_side(start: SectionStart, restart: Option<u32>, last: u32, pages: usize, even_odd: bool, mirror: bool) -> (bool, Option<u32>) {
|
||||
match start {
|
||||
SectionStart::OddPage | SectionStart::EvenPage => {
|
||||
let want_odd = start == SectionStart::OddPage;
|
||||
let continuing_odd = last.saturating_add(1) % 2 == 1;
|
||||
let filler = pages > 0 && continuing_odd != want_odd && (restart.is_none() || even_odd || mirror);
|
||||
let restart = restart.map(|n| if (n % 2 == 1) == want_odd { n } else { n.saturating_add(1) });
|
||||
(filler, restart)
|
||||
}
|
||||
SectionStart::NextPage => {
|
||||
let filler = even_odd && pages > 0 && restart.is_some_and(|n| (n % 2 == 1) != ((pages + 1) % 2 == 1));
|
||||
(filler, restart)
|
||||
}
|
||||
SectionStart::Continuous | SectionStart::NextColumn => (false, restart),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where body text starts: the top margin, or below the header if it's taller.
|
||||
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32 {
|
||||
let Some(id) = header else { return sect.margin_top };
|
||||
|
|
@ -2698,6 +2662,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
|||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
|
||||
let mut before = pl.rp.space_before;
|
||||
let prev_para = pb.prev.clone();
|
||||
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
|
||||
|
|
@ -2714,39 +2679,42 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
|||
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
|
||||
before = (before - after.max(0.0)).max(0.0);
|
||||
}
|
||||
if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take()
|
||||
let mut prev = pb.prev.take();
|
||||
if let Some((style, ctxl, after, _, _)) = &mut prev
|
||||
&& *style == pl.rp.style
|
||||
&& !pb.at_top()
|
||||
{
|
||||
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
|
||||
if style == pl.rp.style {
|
||||
if ctxl {
|
||||
pb.y -= after;
|
||||
after = 0.0;
|
||||
}
|
||||
if pl.rp.contextual_spacing {
|
||||
before = 0.0;
|
||||
}
|
||||
if *ctxl {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
}
|
||||
// Automatic spacing: none between two list items (as in an HTML list; measured in Word:
|
||||
// any two items, of one list or two, at any level).
|
||||
if listed && label.is_some() {
|
||||
if after_auto {
|
||||
pb.y -= after;
|
||||
after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// The space between two paragraphs is the larger of the first's space after and the
|
||||
// second's space before, not their sum.
|
||||
if before > 0.0 && after > 0.0 {
|
||||
before = (before - after).max(0.0);
|
||||
if pl.rp.contextual_spacing {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of the document.
|
||||
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
|
||||
// two items, of one list or two, at any level).
|
||||
if let Some((_, _, after, after_auto, true)) = &mut prev
|
||||
&& label.is_some()
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if *after_auto {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of the document, and it collapses with the space after
|
||||
// the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && block == 0 {
|
||||
before = 0.0;
|
||||
} else if let Some((_, _, after, after_auto, _)) = prev
|
||||
&& (pl.rp.before_auto || after_auto)
|
||||
&& !pb.at_top()
|
||||
{
|
||||
before = (before - after).max(0.0);
|
||||
}
|
||||
pb.y += before;
|
||||
let mut applied_before = before;
|
||||
|
|
|
|||
|
|
@ -15,53 +15,6 @@ use wordcraft_fonts::FaceRef;
|
|||
use crate::NumeralMode;
|
||||
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text};
|
||||
|
||||
/// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings)
|
||||
/// shows its characters. Those fonts are proprietary and usually missing: a substitute has other
|
||||
/// glyphs, or none, at their codes, so each character is drawn as its Unicode equivalent
|
||||
/// ([`wordcraft_doc::symbol_fonts`]) from a face that has it. The text itself is unchanged.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SymbolRun<'a> {
|
||||
family: &'a str,
|
||||
/// The symbol font itself is installed (`face` is it).
|
||||
installed: bool,
|
||||
face: FaceRef,
|
||||
}
|
||||
|
||||
impl<'a> SymbolRun<'a> {
|
||||
fn of(family: &'a str, r: &wordcraft_fonts::word::Resolved) -> Option<Self> {
|
||||
wordcraft_doc::symbol_fonts::is_symbol_font(family).then_some(SymbolRun { family, installed: !r.substituted, face: r.face })
|
||||
}
|
||||
|
||||
/// The character drawn for `c`.
|
||||
fn show(&self, c: char) -> char {
|
||||
if self.installed {
|
||||
if self.face.covers(c) {
|
||||
return c;
|
||||
}
|
||||
// A plain 8-bit code in the installed font: its glyph sits at the private-use code.
|
||||
if let Some(p) = (0x20..=0xFF).contains(&u32::from(c)).then(|| char::from_u32(0xF000 + u32::from(c))).flatten()
|
||||
&& self.face.covers(p)
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
let Some(u) = wordcraft_doc::symbol_fonts::symbol_char(self.family, c) else { return c };
|
||||
// A few list bullets have common look-alikes in far more fonts: use one when no face
|
||||
// has the exact character.
|
||||
let alt = match u {
|
||||
'\u{26AB}' => Some('\u{25CF}'),
|
||||
'\u{25FC}' => Some('\u{25A0}'),
|
||||
'\u{2B9A}' => Some('\u{27A2}'),
|
||||
_ => None,
|
||||
};
|
||||
let has = |ch: char| self.face.covers(ch) || wordcraft_fonts::FontDb::global().fallback_for(ch, self.face.id()).is_some();
|
||||
match alt {
|
||||
Some(a) if !has(u) && has(a) => a,
|
||||
_ => u,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face at a size with the formatting needed to draw it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StyleRun {
|
||||
|
|
@ -704,11 +657,7 @@ impl<'a> Builder<'a> {
|
|||
// Complex-script characters (Persian, Arabic…) use the run's complex-script font, size,
|
||||
// bold and italic.
|
||||
let rc_cs: Option<Arc<ResolvedChar>> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex()));
|
||||
let resolved = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let primary = resolved.face;
|
||||
// Symbol-font characters are laid out as their Unicode equivalents.
|
||||
let sym = SymbolRun::of(&rc.font, &resolved);
|
||||
let show = |c: char| sym.map_or(c, |m| m.show(c));
|
||||
let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face;
|
||||
let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face);
|
||||
// Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when
|
||||
// it is installed and has them.
|
||||
|
|
@ -743,8 +692,6 @@ impl<'a> Builder<'a> {
|
|||
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
|
||||
// A symbol-font character becomes its Unicode symbol (#776).
|
||||
let c = show(c);
|
||||
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
|
||||
// fallback face are picked for the displayed char, so Hindi digits land in an
|
||||
// Arabic-capable face and Western digits in a Latin one.
|
||||
|
|
@ -812,16 +759,7 @@ impl<'a> Builder<'a> {
|
|||
orig_of.push(sub.len());
|
||||
}
|
||||
let shape_text = if substituted { mapped.as_str() } else { sub };
|
||||
let map = |c: char| {
|
||||
let d = show(c);
|
||||
if d != c {
|
||||
d
|
||||
} else if upper {
|
||||
c.to_uppercase().next().unwrap_or(c)
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||||
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
|
||||
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
|
||||
let shaped = if use_levels {
|
||||
|
|
@ -1441,16 +1379,24 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
let label = env.label.as_ref().map(|(text, lvl)| {
|
||||
let lc = p.mark.clone().overlaid(&lvl.chr);
|
||||
let rc = resolve(&lc);
|
||||
// A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font
|
||||
// isn't installed (shaping maps it); the label's text reads as that character too.
|
||||
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
|
||||
// A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has
|
||||
// no glyph at the private-use code: draw the standard character instead, since a
|
||||
// substitute has another glyph, or none, there.
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) {
|
||||
Some(u) if r.substituted || !r.face.covers(c) => u,
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
let before_c = b.clusters.len();
|
||||
let before_g = b.glyphs.len();
|
||||
b.shape_with(text, 0, &rc, None, false);
|
||||
b.shape_with(&text, 0, &rc, None, false);
|
||||
let mut cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
|
||||
// A right-to-left paragraph's label reads right to left too ("1." shows as ".1").
|
||||
if rp.bidi {
|
||||
let lv = bidi_levels(text, true);
|
||||
let lv = bidi_levels(&text, true);
|
||||
let cl_lv: Vec<u8> = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect();
|
||||
let order = visual_order(&cl_lv);
|
||||
cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect();
|
||||
|
|
@ -1465,18 +1411,18 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
glyphs.push(Glyph { dx: g.dx + x, ..*g });
|
||||
}
|
||||
let chars = shown(text.get(c.start..c.end).unwrap_or(""));
|
||||
let chars = text.get(c.start..c.end).unwrap_or("");
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == c.style => {
|
||||
r.glyphs.end = glyphs.len();
|
||||
r.text.push_str(&chars);
|
||||
r.text.push_str(chars);
|
||||
}
|
||||
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars }),
|
||||
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars.to_string() }),
|
||||
}
|
||||
x += c.adv;
|
||||
}
|
||||
b.glyphs.truncate(before_g);
|
||||
Label { text: shown(text), style, glyphs, runs, x: 0.0, width: x }
|
||||
Label { text, style, glyphs, runs, x: 0.0, width: x }
|
||||
});
|
||||
|
||||
let mut pl = ParaLayout {
|
||||
|
|
|
|||
|
|
@ -86,14 +86,6 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [f32; 4] {
|
|||
t.props.cell_margins.or(style.and_then(|s| s.parts.cell_margins)).unwrap_or(DEFAULT_MARGINS)
|
||||
}
|
||||
|
||||
/// The margins (top, left, bottom, right; points) inside cell `cell` of row `row`: its own, else
|
||||
/// the table's defaults, as the layout uses them. The app's ruler measures a cell's indents from
|
||||
/// them.
|
||||
pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: usize) -> [f32; 4] {
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
t.rows.get(row).and_then(|r| r.cells.get(cell)).and_then(|c| c.props.margins).unwrap_or_else(|| default_margins(t, style.as_ref()))
|
||||
}
|
||||
|
||||
/// The most paragraphs [`measure_table_columns`] lays out: a huge table is measured by its first
|
||||
/// rows only, so AutoFit stays quick.
|
||||
const MEASURE_BUDGET: usize = 4000;
|
||||
|
|
|
|||
|
|
@ -86,39 +86,6 @@ fn hit_and_caret_agree() {
|
|||
assert_eq!(up.path, Path::top(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bar_finds_the_line_beside_the_pointer() {
|
||||
// #856: the left margin beside a line is Word's selection bar.
|
||||
let text = format!("{}\nShort.", "The quick brown fox jumps over the lazy dog. ".repeat(8));
|
||||
let mut d = Document::from_text(&text);
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(Table::new(1, 1, 468.0))).unwrap();
|
||||
let l = lay(&d);
|
||||
let p = &l.pages[0];
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
let first = l.selection_bar_at(0, 30.0, c.top + c.height / 2.0).unwrap();
|
||||
assert_eq!(first, Pos::body(0, 0));
|
||||
// The second line of the wrapped paragraph: its start, and its span is the whole line.
|
||||
let line2 = l.selection_bar_at(0, 30.0, c.top + c.height * 1.5).unwrap();
|
||||
assert_eq!(line2.path, Path::top(0));
|
||||
assert!(line2.off > 0);
|
||||
let (a, b, last) = l.line_span(&line2, 0).unwrap();
|
||||
assert_eq!(a, line2);
|
||||
assert!(b.off > a.off && !last);
|
||||
let (_, b, last) = l.line_span(&Pos::body(1, 0), 0).unwrap();
|
||||
assert_eq!(b.off, "Short.".len());
|
||||
assert!(last);
|
||||
// In the text column, the header area, off the page or beside a table: not the bar.
|
||||
assert_eq!(l.selection_bar_at(0, p.body.x + 5.0, c.top + 2.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, 30.0, p.body.y - 10.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, -5.0, c.top + 2.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, f32::NAN, c.top), None);
|
||||
assert_eq!(l.selection_bar_at(9, 30.0, c.top), None);
|
||||
let cell = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![2, 0, 0, 0]), off: 0 }).unwrap();
|
||||
assert_eq!(l.selection_bar_at(0, 30.0, cell.top + cell.height / 2.0), None);
|
||||
// Dragging takes the nearest top-level line at any height.
|
||||
assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletions_leave_the_final_text_layout() {
|
||||
// "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED".
|
||||
|
|
@ -559,42 +526,13 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() {
|
|||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
|
||||
// The label reads as the Unicode bullet (drawn from a real Symbol font where one is installed).
|
||||
assert_eq!(label.text, "\u{2022}");
|
||||
// Where a real Symbol font with the private-use code is installed (Windows), the code stays.
|
||||
let symbol = wordcraft_fonts::word::resolve("Symbol", false, false);
|
||||
let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" };
|
||||
assert_eq!(label.text, expected);
|
||||
assert_eq!(label.glyphs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_text_draws_and_reads_as_its_unicode_symbols() {
|
||||
// Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes
|
||||
// (U+F0FC = check mark, what `w:sym` writes).
|
||||
let mut d = Document::from_text("Hi J\u{F0FC} ok");
|
||||
d.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
let l = lay(&d);
|
||||
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
|
||||
let text: String = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect();
|
||||
assert_eq!(text, "Hi \u{263A}\u{2713} ok");
|
||||
// The stored text is unchanged.
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hi J\u{F0FC} ok");
|
||||
// Without Wingdings, each symbol is drawn from a face that has its Unicode character (never
|
||||
// the substitute's "J" or a missing glyph).
|
||||
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
|
||||
for want in ['\u{263A}', '\u{2713}'] {
|
||||
let run = items.iter().find_map(|i| match i {
|
||||
display::Draw::Glyphs { face, glyphs, text, .. } if text.contains(want) => Some((*face, glyphs.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((face, glyphs)) = run else { panic!("no run for {want}") };
|
||||
if face.covers(want) {
|
||||
assert!(glyphs.iter().any(|g| g.0 == face.glyph_for(want)), "{want}");
|
||||
} else {
|
||||
// No installed face has it (a bare CI machine): never the letter J's glyph.
|
||||
assert!(!glyphs.iter().any(|g| g.0 != 0 && g.0 == face.glyph_for('J')), "{want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_lay_out_cells() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
|
|
@ -927,90 +865,6 @@ fn section_breaks_show_with_formatting_marks() {
|
|||
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_between_paragraphs_is_the_larger_of_after_and_before() {
|
||||
// (space after the first, space before the second, contextual spacing on both) -> gap
|
||||
let gap = |after: f32, before: f32, contextual: bool| -> f32 {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
for (i, (a, b)) in [(after, 0.0), (0.0, before)].into_iter().enumerate() {
|
||||
let p = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||||
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(a), Some(b), Some(contextual));
|
||||
}
|
||||
let l = lay(&d);
|
||||
let ys: Vec<(f32, f32)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, para, .. } = i { Some((*y, para.height)) } else { None }).collect();
|
||||
ys[1].0 - (ys[0].0 + ys[0].1)
|
||||
};
|
||||
// Word's output for docxide-pdf's clean_energy_regulator_guidelines: 6pt after and 6pt before
|
||||
// leave 6pt, not 12pt.
|
||||
assert!((gap(6.0, 6.0, false) - 6.0).abs() < 0.01);
|
||||
assert!((gap(6.0, 12.0, false) - 12.0).abs() < 0.01);
|
||||
assert!((gap(12.0, 6.0, false) - 12.0).abs() < 0.01);
|
||||
assert!((gap(0.0, 6.0, false) - 6.0).abs() < 0.01);
|
||||
// Contextual spacing drops both between paragraphs of one style.
|
||||
assert!(gap(6.0, 12.0, true).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_between_paragraphs_in_a_cell_follows_the_body_rule() {
|
||||
// Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them.
|
||||
let gaps = |contextual: bool| -> Vec<f32> {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
t.rows[0].cells[0].blocks = (0..3)
|
||||
.map(|k| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(&format!("p{k}"), Default::default());
|
||||
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(6.0), Some(6.0), Some(contextual));
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let mut ys: Vec<(u32, f32, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, y, para, .. } if path.0.len() == 4 => Some((path.0[3], *y, para.height)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
ys.sort_by_key(|v| v.0);
|
||||
ys.windows(2).map(|w| w[1].1 - (w[0].1 + w[0].2)).collect()
|
||||
};
|
||||
let near = |got: Vec<f32>, want: [f32; 2]| got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01);
|
||||
// The larger of after and before, not their sum.
|
||||
assert!(near(gaps(false), [6.0, 6.0]), "{:?}", gaps(false));
|
||||
// Contextual spacing drops the space between every pair, not just the first.
|
||||
assert!(near(gaps(true), [0.0, 0.0]), "{:?}", gaps(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// "One" ends the first section on page 1; the second section starts as `start`.
|
||||
let pages = |start: SectionStart, restart: Option<u32>, even_odd: bool| -> Vec<(usize, u32)> {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d.last_section.page_num_start = restart;
|
||||
d.settings.even_odd_headers = even_odd;
|
||||
lay(&d).pages.iter().map(|p| (p.section, p.number)).collect()
|
||||
};
|
||||
// An odd-page break after page 1 leaves page 2 blank; the blank page ends the first section.
|
||||
assert_eq!(pages(SectionStart::OddPage, None, false), [(0, 1), (0, 2), (1, 3)]);
|
||||
assert_eq!(pages(SectionStart::EvenPage, None, false), [(0, 1), (1, 2)]);
|
||||
// Restarting at 1 needs no blank page unless both sides print; at 2 it takes the next number.
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(1), false), [(0, 1), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(2), false), [(0, 1), (1, 3)]);
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
|
||||
// With different odd and even headers, a next-page break restarting at 1 starts on an odd
|
||||
// sheet (Word's output for docxide-pdf's bush_fires_act_comparison).
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(2), true), [(0, 1), (1, 2)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(1), false), [(0, 1), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
|
|
@ -2302,38 +2156,6 @@ fn at_least_rows_add_cell_margins_and_border_bands() {
|
|||
assert!((tops[0] - (72.0 + 0.5 + 5.0)).abs() < 0.01, "first cell text at {}", tops[0]);
|
||||
}
|
||||
|
||||
/// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's;
|
||||
/// each cell's text starts its left margin inside the cell.
|
||||
#[test]
|
||||
fn cell_margins_are_where_cell_text_starts() {
|
||||
let mut d = Document::from_text("");
|
||||
let mut t = Table::new(1, 2, 300.0);
|
||||
t.props.cell_margins = Some([0.0, 9.0, 0.0, 3.0]);
|
||||
t.rows[0].cells[1].props.margins = Some([0.0, 20.0, 0.0, 4.0]);
|
||||
for (c, text) in t.rows[0].cells.iter_mut().zip(["one", "two"]) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
}
|
||||
assert_eq!(cell_margins(&d, &t, 0, 0), [0.0, 9.0, 0.0, 3.0]);
|
||||
assert_eq!(cell_margins(&d, &t, 0, 1), [0.0, 20.0, 0.0, 4.0]);
|
||||
assert_eq!(cell_margins(&d, &t, 5, 9), [0.0, 9.0, 0.0, 3.0], "no such cell: the table's");
|
||||
assert_eq!(cell_margins(&d, &Table::new(1, 1, 100.0), 0, 0), [0.0, 5.4, 0.0, 5.4], "Word's");
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t.clone()))];
|
||||
let l = lay(&d);
|
||||
let items = &l.pages[0].items;
|
||||
for c in 0..2 {
|
||||
let boxed = items.iter().find_map(|it| match it {
|
||||
Placed::Cell { rect, cell, .. } if *cell == c => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
let text_x = items.iter().find_map(|it| match it {
|
||||
Placed::Lines { path, x, .. } if path.0.get(2) == Some(&(c as u32)) => Some(*x),
|
||||
_ => None,
|
||||
});
|
||||
let (boxed, text_x) = (boxed.unwrap(), text_x.unwrap());
|
||||
assert!((text_x - boxed.x - cell_margins(&d, &t, 0, c)[1]).abs() < 0.01, "cell {c}: text at {text_x}, cell at {}", boxed.x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Line count and, per line, how far the text (trailing spaces excluded) reaches.
|
||||
fn justified_lines(text: &str, compat_mode: u32, align: Align) -> (usize, Vec<f32>) {
|
||||
let mut d = Document::from_text(text);
|
||||
|
|
|
|||
|
|
@ -200,24 +200,6 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
|||
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// The natural size in points of a Windows Metafile or Enhanced Metafile picture, and the file
|
||||
/// extension it is stored under (`"wmf"` or `"emf"`), or None for anything else (#802). Very large
|
||||
/// frames are scaled down to 22 inches on the longer side, keeping the shape.
|
||||
pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
|
||||
if !wordcraft_metafile::is_metafile(bytes) {
|
||||
return None;
|
||||
}
|
||||
let pic = wordcraft_metafile::parse(bytes).ok()?;
|
||||
let (w, h) = (pic.width, pic.height);
|
||||
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let k = (1584.0 / w.max(h)).min(1.0);
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if bytes.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
Some((w * k, h * k, ext))
|
||||
}
|
||||
|
||||
/// A metafile (WMF/EMF) picture, parsed once. The geometry comes placed by the metafile crate; the
|
||||
/// bitmaps are pixmaps, one per picture item (None for paths and undecodable bitmaps).
|
||||
struct Vector {
|
||||
|
|
@ -430,44 +412,6 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine,
|
|||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
/// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at
|
||||
/// `scale` pixels per point, starting `margin` points in with the baseline `margin` + the face's
|
||||
/// ascent down: a font list entry. Outlines come straight from the face, not the shared outline
|
||||
/// cache, so a face read just for the preview leaves nothing behind once dropped (#848).
|
||||
pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, w: f32, h: f32, margin: f32, scale: f32) -> Rendered {
|
||||
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
|
||||
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
|
||||
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
|
||||
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
|
||||
let k = size / upem;
|
||||
let view = Affine::scale(scale as f64);
|
||||
let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 };
|
||||
let baseline = margin + ascent * k;
|
||||
let mut x = margin;
|
||||
for g in wordcraft_fonts::shape(face, text, &[], |c| c) {
|
||||
let o = face.glyph_outline(g.gid);
|
||||
ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
x += g.x_advance as f64 * k;
|
||||
if x > w as f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
|
||||
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
|
||||
let face = r.face;
|
||||
|
|
|
|||
|
|
@ -26,17 +26,9 @@ const EDGE_SPEED_MIN: f32 = 45.0;
|
|||
const EDGE_SPEED_MAX: f32 = 720.0;
|
||||
/// Longest frame time trusted for auto-scroll (a stall shouldn't jump the page).
|
||||
const EDGE_MAX_DT: f32 = 0.1;
|
||||
/// Page textures are kept for pages within this many viewport heights of the view; farther ones
|
||||
/// are rendered again when they come back.
|
||||
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
|
||||
|
||||
pub struct CanvasState {
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
/// Page content fingerprints of the current layout, for the texture cache (#811).
|
||||
pub(crate) page_keys: PageKeys,
|
||||
/// Whether the document has tracked formatting changes, moves and deletions, by document
|
||||
/// revision: finding out walks every paragraph, too slow for every frame (#811).
|
||||
markup_facts: std::cell::Cell<Option<((u64, u64), [bool; 3])>>,
|
||||
pub scroll_to_caret: bool,
|
||||
/// A heading navigation click aligns its page against the navigation pane, not just visible.
|
||||
pub(crate) nav_to_caret: bool,
|
||||
|
|
@ -142,20 +134,12 @@ impl CanvasState {
|
|||
pub(crate) fn page_texture(&self, page: usize) -> Option<&TextureHandle> {
|
||||
self.textures.get(&page).map(|(_, t)| t)
|
||||
}
|
||||
|
||||
/// How many pages have a cached rendering.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn texture_count(&self) -> usize {
|
||||
self.textures.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
fn default() -> Self {
|
||||
CanvasState {
|
||||
textures: HashMap::new(),
|
||||
page_keys: PageKeys::default(),
|
||||
markup_facts: std::cell::Cell::new(None),
|
||||
scroll_to_caret: true,
|
||||
nav_to_caret: false,
|
||||
read_wheel_accum: 0.0,
|
||||
|
|
@ -310,23 +294,11 @@ pub fn markup_width(app: &WordApp) -> f32 {
|
|||
// Word shows comments either in balloons (contextual) or in the Comments pane (list); Track
|
||||
// Changes Options can put every revision inline (`Session::balloons_shown`).
|
||||
let s = &app.session;
|
||||
if !s.balloons_shown() {
|
||||
return 0.0;
|
||||
}
|
||||
let m = &s.prefs.markup;
|
||||
let comments = m.comments && !s.doc.comments.is_empty();
|
||||
let [has_formats, has_moves, has_deletions] = match app.canvas.markup_facts.get() {
|
||||
Some((rev, facts)) if rev == (s.document_id(), s.rev()) => facts,
|
||||
_ => {
|
||||
use wordcraft_engine::cmd::{moves, review};
|
||||
let facts = [review::has_format_changes(&s.doc), moves::has_moves(&s.doc), review::has_deletions(&s.doc)];
|
||||
app.canvas.markup_facts.set(Some(((s.document_id(), s.rev()), facts)));
|
||||
facts
|
||||
}
|
||||
};
|
||||
let formats = m.formatting && has_formats;
|
||||
let moves = m.insertions_deletions && has_moves;
|
||||
let deletions = s.revisions_in_balloons() && has_deletions;
|
||||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&s.doc);
|
||||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&s.doc);
|
||||
let deletions = s.revisions_in_balloons() && wordcraft_engine::cmd::review::has_deletions(&s.doc);
|
||||
if s.balloons_shown() && (comments || formats || moves || deletions) { 216.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
|
|
@ -550,24 +522,15 @@ pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
|
|||
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
|
||||
}
|
||||
|
||||
/// Fingerprint of everything outside the layout that changes how pages render: the view's
|
||||
/// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media.
|
||||
/// Taken once per frame.
|
||||
fn view_key(app: &WordApp, dim_body: bool) -> u64 {
|
||||
/// Fingerprint of a page's content for the texture cache.
|
||||
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
scale_px.to_bits().hash(&mut h);
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h);
|
||||
app.session.doc.media.len().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's laid-out content. A layout never changes once made, so this is
|
||||
/// taken once per page and layout ([`PageKeys`]), not every frame.
|
||||
fn page_content_key(page: &Page) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(page.w.to_bits(), page.h.to_bits()).hash(&mut h);
|
||||
for list in [&page.items, &page.header, &page.footer] {
|
||||
list.len().hash(&mut h);
|
||||
|
|
@ -592,44 +555,7 @@ fn page_content_key(page: &Page) -> u64 {
|
|||
}
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The content fingerprints of one layout's pages, taken when a page is first needed (#811).
|
||||
/// Hashing every item of every page each frame cost tens of milliseconds a frame in a long
|
||||
/// table (thousands of cell borders per page), idle or scrolling.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PageKeys {
|
||||
/// The layout these are for; held so its address can't be reused by another one.
|
||||
layout: Option<std::sync::Arc<DocLayout>>,
|
||||
keys: Vec<Option<u64>>,
|
||||
/// Pages fingerprinted since the layout last changed.
|
||||
pub(crate) hashed: usize,
|
||||
}
|
||||
|
||||
impl PageKeys {
|
||||
fn content(&mut self, layout: &std::sync::Arc<DocLayout>, i: usize) -> u64 {
|
||||
if !self.layout.as_ref().is_some_and(|l| std::sync::Arc::ptr_eq(l, layout)) {
|
||||
self.layout = Some(layout.clone());
|
||||
self.keys = vec![None; layout.pages.len()];
|
||||
self.hashed = 0;
|
||||
}
|
||||
if let Some(Some(k)) = self.keys.get(i) {
|
||||
return *k;
|
||||
}
|
||||
let k = layout.pages.get(i).map_or(0, page_content_key);
|
||||
if let Some(slot) = self.keys.get_mut(i) {
|
||||
*slot = Some(k);
|
||||
self.hashed += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture cache key of page `i`: its content, the view, and the raster scale.
|
||||
fn page_key(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, view: u64, scale_px: f32) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(view, scale_px.to_bits(), app.canvas.page_keys.content(layout, i)).hash(&mut h);
|
||||
app.session.doc.media.len().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
|
|
@ -911,19 +837,22 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let t0 = crate::now_ms();
|
||||
let mut rects = Vec::with_capacity(geo.rects.len());
|
||||
let content_origin = ui.min_rect().min;
|
||||
let view = view_key(app, dim_body);
|
||||
for (i, pr) in geo.rects.iter().enumerate() {
|
||||
let sr = pr.translate(content_origin.to_vec2());
|
||||
let Some(page) = layout.pages.get(i) else { continue };
|
||||
let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height()));
|
||||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
// Most reading documents have many columns; skip expensive page hashing for offscreen
|
||||
// pages. We still emit their rectangles for hit testing and the control channel.
|
||||
if !sr.intersects(ui.clip_rect().expand(200.0)) {
|
||||
rects.push(sr);
|
||||
continue;
|
||||
}
|
||||
let key = page_key(app, page, scale_px, dim_body);
|
||||
let mut fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// Hide White Space shows only a band of the page; everything below paints into it.
|
||||
let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot));
|
||||
let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0));
|
||||
// Only pages on screen or with a texture need their key (#811).
|
||||
let cached = app.canvas.textures.contains_key(&i);
|
||||
let key = if visible || cached { page_key(app, &layout, i, view, scale_px) } else { 0 };
|
||||
let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// Render before placing the page: a page re-rendered in this frame (a zoom step, an
|
||||
// edit) then sits on the pixel grid in this frame too, rather than a frame later.
|
||||
if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
|
|
@ -1046,12 +975,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
if rendered > 0 {
|
||||
app.canvas.render_ms = crate::now_ms() - t0;
|
||||
}
|
||||
// Drop textures of pages that no longer exist, and of pages scrolled well out of view
|
||||
// (#811): scrolling through a long document kept every page it had shown in memory.
|
||||
// Drop textures of pages that no longer exist.
|
||||
let n = layout.pages.len();
|
||||
let clip = ui.clip_rect();
|
||||
let near = clip.expand2(vec2(clip.width(), clip.height() * TEXTURE_KEEP_SCREENS));
|
||||
app.canvas.textures.retain(|k, _| *k < n && geo.rects.get(*k).is_some_and(|r| r.translate(content_origin.to_vec2()).intersects(near)));
|
||||
app.canvas.textures.retain(|k, _| *k < n);
|
||||
app.canvas.page_rects = rects.clone();
|
||||
balloons(app, ui, &painter, &rects, &layout, geo.scale);
|
||||
// Find › Reading Highlight.
|
||||
|
|
@ -1325,15 +1251,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if resp.hovered() || app.canvas.obj_drag.is_some() {
|
||||
let latest = ui.input(|i| i.pointer.latest_pos());
|
||||
let over_object = latest.and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
|
||||
// The selection bar: Word shows a right-pointing arrow there; egui has none, so the
|
||||
// plain arrow stands in (#856).
|
||||
let in_bar = app.session.sel.focus.story == StoryRef::Body
|
||||
&& (app.canvas.unit_drag.as_ref().is_some_and(|d| d.unit == "line")
|
||||
|| latest.and_then(|p| page_at(&rects, &layout, geo.scale, p)).is_some_and(|(pg, x, y)| layout.selection_bar_at(pg, x, y).is_some()));
|
||||
let fallback = if in_bar { egui::CursorIcon::Default } else { egui::CursorIcon::Text };
|
||||
ui.ctx().set_cursor_icon(over_object.unwrap_or(fallback));
|
||||
let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
|
||||
ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text));
|
||||
}
|
||||
crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale);
|
||||
crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale);
|
||||
|
|
@ -1875,42 +1794,6 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
if app.session.math.is_some() {
|
||||
let _ = app.run("equation.exit", json!({}));
|
||||
}
|
||||
// The selection bar (the left margin beside body text, #856): a click selects the line,
|
||||
// a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; Shift+click
|
||||
// extends by lines; dragging grows the selection by the clicked unit.
|
||||
if story == StoryRef::Body
|
||||
&& !mods.alt
|
||||
&& let Some(line) = layout.selection_bar_at(page, x, y)
|
||||
{
|
||||
app.canvas.column_drag = None;
|
||||
app.session.page_hint = page;
|
||||
let lj = serde_json::to_value(&line).unwrap_or_default();
|
||||
let (unit, start, end) = if mods.shift {
|
||||
let anchor = app.session.sel.anchor.clone();
|
||||
let aj = serde_json::to_value(&anchor).unwrap_or_default();
|
||||
let _ = app.run("select.dragUnit", json!({"unit": "line", "start": aj, "end": aj, "pos": lj}));
|
||||
("line", anchor.clone(), anchor)
|
||||
} else {
|
||||
let unit = match clicks {
|
||||
_ if mods.command => "document",
|
||||
3.. => "document",
|
||||
2 => "paragraph",
|
||||
_ => "line",
|
||||
};
|
||||
let _ = app.run("caret.set", json!({"pos": lj}));
|
||||
let id = match unit {
|
||||
"document" => "select.all",
|
||||
"paragraph" => "select.paragraph",
|
||||
_ => "select.line",
|
||||
};
|
||||
let _ = app.run(id, json!({}));
|
||||
let (start, end) = app.session.sel.ordered();
|
||||
(unit, start, end)
|
||||
};
|
||||
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
|
|
@ -1969,13 +1852,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some(d) = app.canvas.unit_drag.as_mut()
|
||||
{
|
||||
// Lines grow line by line wherever the pointer is across; the whole document can't grow.
|
||||
let target = match d.unit {
|
||||
"line" => layout.body_line_at(page, y),
|
||||
"document" => None,
|
||||
_ => layout.hit(page, x, y, story),
|
||||
};
|
||||
if let Some(pos) = target
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& pos != d.last
|
||||
{
|
||||
d.last = pos.clone();
|
||||
|
|
@ -2068,19 +1945,14 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
] {
|
||||
margin_handle(app, ui, edge, zone, |p| (p.x - x0) / scale, §);
|
||||
}
|
||||
// In a table, its column borders; registered before the indent markers, which keep the
|
||||
// pointer where they overlap.
|
||||
crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale);
|
||||
// Indent markers for the caret's paragraph (relative to its column, or in a table to the
|
||||
// cell's text).
|
||||
let (col_x, w) = crate::table_ruler::text_area(app, layout, pi).unwrap_or((page.body.x, page.body.w));
|
||||
// Where the paragraph starts, from the page's start edge (the right edge when right-to-left).
|
||||
let start = if rtl { page.w - (col_x + w) } else { col_x };
|
||||
// Indent markers for the caret's paragraph (relative to its column).
|
||||
let col_x = page.body.x;
|
||||
if let Some(rp) = rp {
|
||||
// Indents and tabs are measured from the paragraph's start edge: the right margin of a
|
||||
// right-to-left paragraph, where the start-indent markers sit.
|
||||
let px = |s: f32| sx(start + s);
|
||||
let dist_at = |x: f32| s_at(x) - start;
|
||||
let w = page.body.w;
|
||||
let px = |s: f32| sx(origin + s);
|
||||
let dist_at = |x: f32| s_at(x) - origin;
|
||||
let first = px(rp.indent_left + rp.indent_first);
|
||||
let left = px(rp.indent_left);
|
||||
let right = px(w - rp.indent_right);
|
||||
|
|
@ -2127,7 +1999,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = dist_at(pp.x).clamp(-col_x, (w - 18.0).max(-col_x));
|
||||
let pt = dist_at(pp.x).clamp(-col_x, page.body.w - 18.0);
|
||||
let snapped = (pt / 4.5).round() * 4.5;
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
|
|
@ -2150,7 +2022,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = w - dist_at(pp.x);
|
||||
let pt = page.body.w - dist_at(pp.x);
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
|
|
@ -2192,8 +2064,6 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
break;
|
||||
}
|
||||
}
|
||||
// In a table, its rows' bottom borders.
|
||||
crate::table_ruler::rows(app, ui, layout, pi, vbar, y0, scale);
|
||||
}
|
||||
|
||||
/// One mark drawn along a horizontal ruler.
|
||||
|
|
@ -2826,104 +2696,6 @@ mod tests {
|
|||
assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6)));
|
||||
}
|
||||
|
||||
/// #856: the left margin beside body text is Word's selection bar. A click selects the line,
|
||||
/// a double-click the paragraph, a triple-click (or Ctrl/⌘+click) the document; dragging
|
||||
/// selects whole lines and Shift+click extends by lines.
|
||||
#[test]
|
||||
fn selection_bar_clicks_select_lines() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": format!("{}\rSecond para\rThird para", long.trim_end())})).unwrap();
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
// A point in the left margin beside the line `li` lines below the one holding `pos`.
|
||||
let margin = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: Pos, li: f32| {
|
||||
let c = h.state_mut().session.layout().caret(&pos).expect("caret");
|
||||
page_to_screen(h.state_mut(), c.page, 30.0, c.top + c.height * (li + 0.5)).expect("on screen")
|
||||
};
|
||||
let line_start = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
|
||||
let (rects, scale) = (h.state().canvas.page_rects.clone(), h.state().canvas.scale);
|
||||
let layout = h.state_mut().session.layout();
|
||||
let (pg, x, y) = page_at(&rects, &layout, scale, at).expect("page");
|
||||
layout.selection_bar_at(pg, x, y).expect("in the selection bar")
|
||||
};
|
||||
let span = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: &Pos| h.state_mut().session.layout().line_span(pos, 0).expect("line");
|
||||
let button =
|
||||
|at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let clicks_drag = |h: &mut egui_kittest::Harness<'_, WordApp>, from: Pos2, to: Pos2, clicks: usize| {
|
||||
h.event(Event::PointerMoved(from));
|
||||
for i in 0..clicks {
|
||||
h.event(button(from, true));
|
||||
h.step();
|
||||
if i + 1 < clicks {
|
||||
h.event(button(from, false));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
for k in 1..=4 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(button(to, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
let pause = |h: &mut egui_kittest::Harness<'_, WordApp>| {
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
// Click beside the second line: that whole line is selected, not a caret at its start.
|
||||
let at = margin(&mut h, Pos::body(0, 0), 1.0);
|
||||
let l2 = line_start(&mut h, at);
|
||||
assert!(l2.off > 0 && l2.path == wordcraft_doc::Path::top(0));
|
||||
clicks_drag(&mut h, at, at, 1);
|
||||
let (_, l2_end, _) = span(&mut h, &l2);
|
||||
assert_eq!(h.state().session.sel.ordered(), (l2.clone(), l2_end.clone()), "the line");
|
||||
// Double-click: the paragraph, mark included.
|
||||
pause(&mut h);
|
||||
clicks_drag(&mut h, at, at, 2);
|
||||
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(1, 0)), "the paragraph");
|
||||
// Triple-click: the whole document.
|
||||
pause(&mut h);
|
||||
clicks_drag(&mut h, at, at, 3);
|
||||
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 10)), "everything");
|
||||
// Drag from beside the first line down past the third: three whole lines.
|
||||
pause(&mut h);
|
||||
let (from, to) = (margin(&mut h, Pos::body(0, 0), 0.0), margin(&mut h, Pos::body(0, 0), 2.0));
|
||||
let l3 = line_start(&mut h, to);
|
||||
clicks_drag(&mut h, from, to, 1);
|
||||
let (_, l3_end, _) = span(&mut h, &l3);
|
||||
let sel = h.state().session.sel.clone();
|
||||
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), l3_end), "three lines");
|
||||
// Shift+click beside "Second para" extends by lines, to that paragraph's mark.
|
||||
pause(&mut h);
|
||||
let second = margin(&mut h, Pos::body(1, 0), 0.0);
|
||||
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.step();
|
||||
clicks_drag(&mut h, second, second, 1);
|
||||
h.event(Event::ModifiersChanged(Modifiers::default()));
|
||||
h.step();
|
||||
let sel = h.state().session.sel.clone();
|
||||
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), Pos::body(2, 0)), "extended by lines");
|
||||
// Hovering the bar shows the arrow, not the I-beam.
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
|
||||
}
|
||||
|
||||
/// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the
|
||||
/// comment has the new text as one undo step.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -301,15 +301,8 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
|||
ptrack,
|
||||
t.text_dim,
|
||||
);
|
||||
if app.ui.keytips == crate::keytips::Phase::Tabs {
|
||||
app.keytip_rects.push((r, crate::keytips::SEARCH_LETTER.to_string()));
|
||||
}
|
||||
let tip = match crate::widgets::shortcut_text(app, "tools.search") {
|
||||
k if k.is_empty() => tl!("Search").to_string(),
|
||||
k => format!("{} ({k})", tl!("Search")),
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
let _ = app.run("tools.search", json!({}));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "commands"}));
|
||||
}
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,25 +31,6 @@ pub struct FindExtra {
|
|||
pub to_replace: bool,
|
||||
}
|
||||
|
||||
/// The tabs of the Find and Replace dialog (#712).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FindTab {
|
||||
#[default]
|
||||
Find,
|
||||
Replace,
|
||||
GoTo,
|
||||
}
|
||||
|
||||
/// What Go To moves to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GoToWhat {
|
||||
#[default]
|
||||
Page,
|
||||
Bookmark,
|
||||
}
|
||||
|
||||
/// The Special ▾ menu: (name, code) for Find what, without and with wildcards.
|
||||
const SPECIAL_FIND: &[(&str, &str)] = &[
|
||||
("Paragraph Mark", "^p"),
|
||||
|
|
@ -221,15 +202,14 @@ pub enum Dialog {
|
|||
regex: bool,
|
||||
/// Search options under "Search Options" (#543).
|
||||
extra: FindExtra,
|
||||
/// Find, Replace or Go To, one dialog as in Word (#712).
|
||||
tab: FindTab,
|
||||
goto_what: GoToWhat,
|
||||
/// Go To's box: a page number (`+2`, `-1` move from the current page) or a bookmark.
|
||||
goto_target: String,
|
||||
replace_mode: bool,
|
||||
message: String,
|
||||
/// Put the caret in Find what on the first frame, so typing searches (#772).
|
||||
focus: bool,
|
||||
},
|
||||
Goto {
|
||||
page: String,
|
||||
},
|
||||
InsertTable {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
|
|
@ -321,10 +301,6 @@ pub enum Dialog {
|
|||
},
|
||||
Commands {
|
||||
query: String,
|
||||
/// Opened by a shortcut that may also send its key's text (Alt+Q's "q", in the same frame
|
||||
/// or the next): that text isn't typed into the box; the next key pressed ends this (#819).
|
||||
#[serde(skip)]
|
||||
swallow: bool,
|
||||
},
|
||||
/// Columns, Symbol and Field ([`crate::dialogs_insert`], #321).
|
||||
Insert(Box<crate::dialogs_insert::InsertDialog>),
|
||||
|
|
@ -872,9 +848,9 @@ impl Dialog {
|
|||
Dialog::Paragraph { .. } => "paragraph",
|
||||
Dialog::Tabs { .. } => "tabs",
|
||||
Dialog::Borders { .. } => "borders",
|
||||
Dialog::Find { tab: FindTab::Find, .. } => "find",
|
||||
Dialog::Find { tab: FindTab::Replace, .. } => "replace",
|
||||
Dialog::Find { tab: FindTab::GoTo, .. } => "goto",
|
||||
Dialog::Find { replace_mode: false, .. } => "find",
|
||||
Dialog::Find { .. } => "replace",
|
||||
Dialog::Goto { .. } => "goto",
|
||||
Dialog::InsertTable { .. } => "insertTable",
|
||||
Dialog::PageSetup { .. } => "pageSetup",
|
||||
Dialog::Link { .. } => "link",
|
||||
|
|
@ -1030,7 +1006,7 @@ impl Dialog {
|
|||
};
|
||||
Dialog::Borders { form: Box::new(crate::dialogs_para::BordersForm::read(app, tab)?) }
|
||||
}
|
||||
"find" | "replace" | "goto" => Dialog::Find {
|
||||
"find" | "replace" => Dialog::Find {
|
||||
query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() },
|
||||
replace: app.session.find.replace.clone(),
|
||||
match_case: app.session.find.match_case,
|
||||
|
|
@ -1049,16 +1025,11 @@ impl Dialog {
|
|||
|| app.session.find.ignore_space,
|
||||
to_replace: false,
|
||||
},
|
||||
tab: match name {
|
||||
"replace" => FindTab::Replace,
|
||||
"goto" => FindTab::GoTo,
|
||||
_ => FindTab::Find,
|
||||
},
|
||||
goto_what: GoToWhat::Page,
|
||||
goto_target: String::new(),
|
||||
replace_mode: name == "replace",
|
||||
message: String::new(),
|
||||
focus: true,
|
||||
},
|
||||
"goto" => Dialog::Goto { page: String::new() },
|
||||
"insertTable" => Dialog::InsertTable { rows: 2, cols: 5 },
|
||||
"pageSetup" => {
|
||||
let sp = wordcraft_engine::cmd::page::sect(&app.session);
|
||||
|
|
@ -1116,7 +1087,7 @@ impl Dialog {
|
|||
let regions = table_regions(app, &id);
|
||||
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"commands" => Dialog::Commands { query: String::new(), swallow: true },
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() },
|
||||
"tableProperties" => {
|
||||
let form = Box::new(TableForm::read(app)?);
|
||||
|
|
@ -1323,7 +1294,9 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
|||
Dialog::Paragraph { .. } => "Paragraph",
|
||||
Dialog::Tabs { .. } => "Tabs",
|
||||
Dialog::Borders { .. } => "Borders and Shading",
|
||||
Dialog::Find { replace_mode: false, .. } => "Find",
|
||||
Dialog::Find { .. } => "Find and Replace",
|
||||
Dialog::Goto { .. } => "Go To",
|
||||
Dialog::InsertTable { .. } => "Insert Table",
|
||||
Dialog::PageSetup { .. } => "Page Setup",
|
||||
Dialog::Link { .. } => "Insert Hyperlink",
|
||||
|
|
@ -1693,20 +1666,15 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form),
|
||||
Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form),
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, extra, tab, goto_what, goto_target, message, focus } => {
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, extra, replace_mode, message, focus } => {
|
||||
ui.horizontal(|ui| {
|
||||
for (t, label) in [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")] {
|
||||
if ui.selectable_label(*tab == t, tl!(label)).clicked() && *tab != t {
|
||||
*tab = t;
|
||||
message.clear();
|
||||
}
|
||||
if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() {
|
||||
*replace_mode = false;
|
||||
}
|
||||
if ui.selectable_label(*replace_mode, tl!("Replace")).clicked() {
|
||||
*replace_mode = true;
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if *tab == FindTab::GoTo {
|
||||
return goto_body(app, ui, goto_what, goto_target, message);
|
||||
}
|
||||
let replace_mode = *tab == FindTab::Replace;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Find what:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new("find_what")).desired_width(240.0));
|
||||
|
|
@ -1717,7 +1685,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
extra.to_replace = false;
|
||||
}
|
||||
});
|
||||
if replace_mode {
|
||||
if *replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Replace with:"));
|
||||
if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() {
|
||||
|
|
@ -1766,7 +1734,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
"ignorePunct": extra.ignore_punct,
|
||||
"ignoreSpace": extra.ignore_space,
|
||||
});
|
||||
if !replace_mode {
|
||||
if !*replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
let lit = app.session.find.highlight;
|
||||
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
|
||||
|
|
@ -1785,7 +1753,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if replace_mode {
|
||||
if *replace_mode {
|
||||
if ui.button(tl!("Replace All")).clicked() {
|
||||
match app.run("edit.replaceAll", opts.clone()) {
|
||||
Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]),
|
||||
|
|
@ -1809,6 +1777,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
});
|
||||
close
|
||||
}
|
||||
Dialog::Goto { page } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Enter page number:"));
|
||||
ui.text_edit_singleline(page);
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, tl!("Go To"));
|
||||
if ok && let Ok(n) = page.trim().parse::<u64>() {
|
||||
let _ = app.run("edit.goto", json!({"page": n}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Enclose { style, text, shape } => {
|
||||
ui.label(egui::RichText::new(tl!("Style")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -2233,44 +2212,35 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f),
|
||||
Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f),
|
||||
Dialog::Commands { query, swallow } => {
|
||||
if *swallow {
|
||||
ui.ctx().input_mut(|i| i.events.retain(|e| !swallow_shortcut_text(swallow, e)));
|
||||
}
|
||||
Dialog::Commands { query } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0));
|
||||
r.request_focus();
|
||||
let q = query.to_lowercase();
|
||||
let localized_q = crate::i18n::lowercase(query);
|
||||
let reg = app.session.registry.clone();
|
||||
// An empty box suggests a few common commands, as Word's Search does.
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Suggested")).font(semibold(12.5)));
|
||||
SEARCH_SUGGESTIONS.iter().filter_map(|id| reg.get(id)).collect()
|
||||
} else {
|
||||
reg.all().iter().filter(|c| command_matches(c, query)).collect()
|
||||
};
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
!q.is_empty()
|
||||
&& (c.label.to_lowercase().contains(&q)
|
||||
|| crate::i18n::lowercase(tl!(c.label)).contains(&localized_q)
|
||||
|| c.id.to_lowercase().contains(&q)
|
||||
|| c.location.to_lowercase().contains(&q)
|
||||
|| crate::i18n::lowercase(&crate::i18n::location(c.location)).contains(&localized_q))
|
||||
})
|
||||
.collect();
|
||||
hits.truncate(14);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten();
|
||||
for c in &hits {
|
||||
let mut close = false;
|
||||
for c in hits {
|
||||
let sc = crate::widgets::shortcut_text(app, c.id);
|
||||
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
|
||||
run = Some(c.id);
|
||||
let _ = app.run(c.id, json!({}));
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
// Search also finds the words in the document (the Navigation pane's results).
|
||||
let text = query.trim().to_string();
|
||||
let mut find = false;
|
||||
if !text.is_empty() {
|
||||
ui.separator();
|
||||
let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]);
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected),
|
||||
Dialog::PasteSpecial { formats, choice, payload, note } => {
|
||||
|
|
@ -2359,14 +2329,6 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
.inner
|
||||
});
|
||||
let buttons = buttons.inner;
|
||||
// The keyboard alone answers, as in Word (#861): Save has focus when the prompt opens,
|
||||
// so Enter saves, and the arrow keys (egui's focus navigation) or Tab move between the
|
||||
// buttons, shown as Save, Don't Save, Cancel.
|
||||
if ui.memory(|m| m.focused().is_none())
|
||||
&& let Some((save, _)) = buttons.get(2)
|
||||
{
|
||||
save.request_focus();
|
||||
}
|
||||
// Enter clicks the focused button; it means Save only when none of them has focus.
|
||||
let focused = buttons.iter().any(|(b, _)| b.has_focus());
|
||||
let answer = if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
|
|
@ -3029,145 +2991,6 @@ fn describe(st: &Value) -> String {
|
|||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// What Search lists before anything is typed: common commands (#819).
|
||||
const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "file.print"];
|
||||
|
||||
/// Search's "Find … in the document": the Navigation pane opens on its results for `text`.
|
||||
fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string()));
|
||||
app.ui.nav_tab = "results".into();
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
let _ = app.run("edit.find", json!({"text": text}));
|
||||
}
|
||||
|
||||
/// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`):
|
||||
/// a plain key press first means the user is typing, and one swallowed text is all there is.
|
||||
fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool {
|
||||
match e {
|
||||
egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => {
|
||||
*swallow = false;
|
||||
false
|
||||
}
|
||||
egui::Event::Text(_) if *swallow => {
|
||||
*swallow = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Search's filter: every word of the query is in the command's label, location or id, in English
|
||||
/// or in the interface language, so "insert table" finds Insert › Tables › Table (#819).
|
||||
fn command_matches(c: &wordcraft_engine::CommandSpec, query: &str) -> bool {
|
||||
if query.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let hay = format!(
|
||||
"{} {} {} {} {}",
|
||||
c.label.to_lowercase(),
|
||||
c.location.to_lowercase(),
|
||||
c.id.to_lowercase(),
|
||||
crate::i18n::lowercase(tl!(c.label)),
|
||||
crate::i18n::lowercase(&crate::i18n::location(c.location))
|
||||
);
|
||||
query.split_whitespace().all(|w| hay.contains(&w.to_lowercase()) || hay.contains(&crate::i18n::lowercase(w)))
|
||||
}
|
||||
|
||||
/// The Find and Replace dialog's Go To tab (#712): a page (a number, or `+2` / `-1` from the
|
||||
/// current page; empty, Next and Previous step a page) or a bookmark. Returns true to close.
|
||||
fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut String, message: &mut String) -> bool {
|
||||
// The document's bookmarks, only while they are listed (finding them walks the document).
|
||||
let bookmarks: Vec<String> = if *what == GoToWhat::Bookmark {
|
||||
app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Go to what:"));
|
||||
for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] {
|
||||
if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w {
|
||||
*what = w;
|
||||
target.clear();
|
||||
message.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| match what {
|
||||
GoToWhat::Page => {
|
||||
ui.label(tl!("Enter page number:"));
|
||||
ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).request_focus();
|
||||
ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak());
|
||||
}
|
||||
GoToWhat::Bookmark => {
|
||||
ui.label(tl!("Enter bookmark name:"));
|
||||
egui::ComboBox::from_id_salt("goto_bookmark").width(220.0).selected_text(target.as_str()).show_ui(ui, |ui| {
|
||||
for b in &bookmarks {
|
||||
ui.selectable_value(target, b.clone(), b.as_str());
|
||||
}
|
||||
});
|
||||
if bookmarks.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).weak());
|
||||
}
|
||||
let mut close = false;
|
||||
let mut go: Option<Value> = None;
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let page_step = *what == GoToWhat::Page && target.trim().is_empty();
|
||||
if page_step && ui.button(tl!("Previous")).clicked() {
|
||||
go = Some(json!({"pageBy": -1}));
|
||||
}
|
||||
let label = if page_step { tl!("Next") } else { tl!("Go To") };
|
||||
if ui.button(label).clicked() || enter {
|
||||
go = match what {
|
||||
GoToWhat::Page => match goto_page(target) {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
*message = tl!("Type a page number, or +2 / -1.").to_string();
|
||||
None
|
||||
}
|
||||
},
|
||||
GoToWhat::Bookmark if !target.is_empty() => Some(json!({"bookmark": target.clone()})),
|
||||
GoToWhat::Bookmark => None,
|
||||
};
|
||||
}
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if let Some(params) = go {
|
||||
match app.run("edit.goto", params) {
|
||||
Ok(_) => message.clear(),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
close
|
||||
}
|
||||
|
||||
/// Go To's page box as `edit.goto` params: empty steps one page on, `+n` / `-n` move from the
|
||||
/// current page, a number is that page. `None` for anything else.
|
||||
fn goto_page(text: &str) -> Option<Value> {
|
||||
let t = text.trim().replace('\u{2212}', "-");
|
||||
if t.is_empty() {
|
||||
return Some(json!({"pageBy": 1}));
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix('+') {
|
||||
return rest.parse::<i64>().ok().filter(|n| *n >= 0).map(|n| json!({"pageBy": n}));
|
||||
}
|
||||
if t.starts_with('-') {
|
||||
return t.parse::<i64>().ok().map(|n| json!({"pageBy": n}));
|
||||
}
|
||||
t.parse::<u64>().ok().filter(|n| *n >= 1).map(|n| json!({"page": n}))
|
||||
}
|
||||
|
||||
/// path data; Insert (or a double click) runs `insert.icon`. Returns true to close.
|
||||
fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool {
|
||||
use wordcraft_render::icon_lib::{self, Category};
|
||||
|
|
@ -3280,31 +3103,6 @@ mod tests {
|
|||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
/// Go To's page box (#712): a number, +n / -n from the current page (a typographic minus
|
||||
/// too), empty for the next page; anything else is refused.
|
||||
#[test]
|
||||
fn go_to_reads_its_page_box() {
|
||||
assert_eq!(goto_page("7"), Some(json!({"page": 7})));
|
||||
assert_eq!(goto_page(" +2 "), Some(json!({"pageBy": 2})));
|
||||
assert_eq!(goto_page("-1"), Some(json!({"pageBy": -1})));
|
||||
assert_eq!(goto_page("\u{2212}3"), Some(json!({"pageBy": -3})));
|
||||
assert_eq!(goto_page(""), Some(json!({"pageBy": 1})));
|
||||
for junk in ["0", "abc", "+x", "1.5", "99999999999999999999999"] {
|
||||
assert_eq!(goto_page(junk), None, "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Find, Replace and Go To are one dialog with three tabs (#712).
|
||||
#[test]
|
||||
fn find_replace_and_go_to_are_one_dialog() {
|
||||
let mut app = app_with("One");
|
||||
for (name, tab) in [("find", FindTab::Find), ("replace", FindTab::Replace), ("goto", FindTab::GoTo)] {
|
||||
let _ = app.run("ui.dialog", json!({"name": name}));
|
||||
assert!(matches!(&app.dialog, Some(Dialog::Find { tab: t, .. }) if *t == tab), "{name}");
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.name()), Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with(text: &str) -> WordApp {
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let _ = app.run("document.setText", json!({"text": text}));
|
||||
|
|
@ -3315,40 +3113,6 @@ mod tests {
|
|||
app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #819: the text a shortcut sends with its key isn't typed into the box it opened, whether it
|
||||
/// comes in the same frame or the next; real typing (a key press first) is.
|
||||
#[test]
|
||||
fn shortcut_text_is_swallowed_once() {
|
||||
let key = |key, alt| egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { alt, ..Default::default() },
|
||||
};
|
||||
let text = |t: &str| egui::Event::Text(t.into());
|
||||
let run = |events: &[egui::Event]| {
|
||||
let mut swallow = true;
|
||||
events.iter().filter(|e| !swallow_shortcut_text(&mut swallow, e)).cloned().collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(&[key(egui::Key::Q, true), text("q")]), vec![key(egui::Key::Q, true)], "Alt+Q's q, same frame");
|
||||
assert_eq!(run(&[text("q"), text("b")]), vec![text("b")], "the q a frame late; then typing");
|
||||
assert_eq!(run(&[key(egui::Key::B, false), text("b")]), vec![key(egui::Key::B, false), text("b")], "a click-opened box takes typing");
|
||||
}
|
||||
|
||||
/// #819: Search matches every word anywhere in the label, location or id, in any order.
|
||||
#[test]
|
||||
fn command_search_matches_every_word() {
|
||||
let app = app_with("");
|
||||
let reg = app.session.registry.clone();
|
||||
let table = reg.get("insert.table").unwrap();
|
||||
assert!(command_matches(table, "insert table"), "the box's own example");
|
||||
assert!(command_matches(table, " Table INSERT "), "any order, any case, extra spaces");
|
||||
assert!(!command_matches(table, "insert chart"), "every word has to match");
|
||||
assert!(!command_matches(table, ""), "an empty box lists nothing");
|
||||
assert!(!command_matches(table, " "));
|
||||
}
|
||||
|
||||
/// Ordinary Turkish queries must find dotted-I labels, while canonical English labels and
|
||||
/// ASCII command IDs retain their old case-insensitive search in every interface language.
|
||||
#[test]
|
||||
|
|
@ -3383,7 +3147,7 @@ mod tests {
|
|||
("en", "INSERT.TABLE", "Table — Insert › Tables"),
|
||||
] {
|
||||
h.state_mut().ui.language = language.into();
|
||||
h.state_mut().dialog = Some(Dialog::Commands { query: query.into(), swallow: false });
|
||||
h.state_mut().dialog = Some(Dialog::Commands { query: query.into() });
|
||||
h.run_steps(4);
|
||||
assert!(
|
||||
h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains(expected)).is_some(),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ pub enum FileDialogRequest {
|
|||
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
|
||||
pub type FileDialogAnswer = Result<Option<String>, String>;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
|
||||
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
|
||||
|
|
|
|||
|
|
@ -2462,5 +2462,3 @@
|
|||
AutoFit Column by Double-Clicking Автопобиране на колона с двойно щракване
|
||||
Insert Caption… Вмъкване на надпис…
|
||||
Table Properties… Свойства на таблица…
|
||||
Search Търсене
|
||||
Title Bar Заглавна лента
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -2078,5 +2078,3 @@
|
|||
Correct accidental usage of cAPS LOCK key Opravovat náhodné použití klávesy cAPS LOCK
|
||||
Region: Oblast:
|
||||
Automatic ({region}) Automaticky ({region})
|
||||
Search Hledat
|
||||
Title Bar Záhlaví okna
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -2072,5 +2072,3 @@
|
|||
Correct accidental usage of cAPS LOCK key Unbeabsichtigtes Verwenden der fESTSTELLTASTE korrigieren
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatisch ({region})
|
||||
Search Suchen
|
||||
Title Bar Titelleiste
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -167,7 +167,6 @@
|
|||
Border Painter Pincel de bordes
|
||||
Border\nPainter Pincel de\nbordes
|
||||
Borders Bordes
|
||||
Search Buscar
|
||||
Show the mini toolbar when text is selected Mostrar la minibarra de herramientas al seleccionar texto
|
||||
Spaces Espacios
|
||||
Spelling Ortografía
|
||||
|
|
@ -184,7 +183,6 @@
|
|||
Page Border Borde de página
|
||||
Apply to: Aplicar a:
|
||||
These marks show even when Show/Hide ¶ is off. Estas marcas se muestran aunque Mostrar u ocultar ¶ esté desactivado.
|
||||
Title Bar Barra de título
|
||||
Typing replaces selected text Lo que escribes reemplaza el texto seleccionado
|
||||
Update fields before printing Actualizar campos antes de imprimir
|
||||
Use the Insert key to switch overtype on and off Usar la tecla Insert para activar y desactivar la sobrescritura
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -664,7 +664,6 @@
|
|||
Save as Template Salvesta mallina
|
||||
Save documents Dokumentide salvestamine
|
||||
Saved Salvestatud
|
||||
Search Otsi
|
||||
Search Commands Otsi käske
|
||||
Search commands and help Otsi käske ja abi
|
||||
Search document Otsi dokumendist
|
||||
|
|
@ -815,7 +814,6 @@
|
|||
Tight Tihe
|
||||
TOC Heading Sisukorra pealkiri
|
||||
TOC {n} Sisukord {n}
|
||||
Title Bar Tiitliriba
|
||||
Top Border Ülemine ääris
|
||||
Top of Page Lehe ülaserv
|
||||
Top: Ülal:
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -101,7 +101,7 @@
|
|||
Number format Format des numéros
|
||||
Number style for this level: Style de numéros pour ce niveau :
|
||||
Outside border Bordure extérieure
|
||||
Overtype Refrappe
|
||||
Overtype Remplacement
|
||||
Outside: Extérieur :
|
||||
Restart list after: Redémarrer la liste après :
|
||||
Right border Bordure droite
|
||||
|
|
@ -1627,8 +1627,8 @@
|
|||
SmartArt Design Création SmartArt
|
||||
SmartArt Styles Styles SmartArt
|
||||
Text Pane Volet Texte
|
||||
Promote to Heading 1 Élever au niveau Titre 1
|
||||
Demote to Body Text Abaisser au niveau Corps de texte
|
||||
Promote to Heading 1 Promouvoir en Titre 1
|
||||
Demote to Body Text Abaisser en corps de texte
|
||||
Move Up Monter
|
||||
Move Down Descendre
|
||||
Collapse Réduire
|
||||
|
|
@ -1649,7 +1649,7 @@
|
|||
Show Text Formatting Afficher la mise en forme du texte
|
||||
Close Outline View Fermer le mode Plan
|
||||
Outline State État du plan
|
||||
Outlining Mode Plan
|
||||
Outlining Plan
|
||||
Outline Tools Outils Plan
|
||||
A, B, C, … A, B, C, …
|
||||
Above the item Au-dessus de l’élément
|
||||
|
|
@ -1862,7 +1862,7 @@
|
|||
# Interface strings added on main since this catalog was first written.
|
||||
As you type Pendant la frappe
|
||||
Asian text font: Police du texte asiatique :
|
||||
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous avez cessé de taper.
|
||||
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous arrêtez de taper.
|
||||
Centimeters Centimètres
|
||||
Check spelling as you type Vérifier l’orthographe pendant la frappe
|
||||
Click to switch back to inserting Cliquez pour revenir au mode insertion
|
||||
|
|
@ -1882,8 +1882,8 @@
|
|||
Millimeters Millimètres
|
||||
New comments and tracked changes are recorded under this name and these initials. Les nouveaux commentaires et les modifications suivies sont enregistrés sous ce nom et ces initiales.
|
||||
New word Nouveau mot
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Utilisez Ajouter au dictionnaire sur un mot mal orthographié pour l’ajouter ici.
|
||||
Overtype (typing replaces the characters after the caret) Refrappe (la frappe remplace les caractères situés après le curseur)
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Les mots ajoutés avec Ajouter au dictionnaire sur un mot mal orthographié apparaissent ici.
|
||||
Overtype (typing replaces the characters after the caret) Remplacement (la frappe remplace les caractères situés après le curseur)
|
||||
Page display Affichage de la page
|
||||
Paragraph marks Marques de paragraphe
|
||||
Personalize Personnaliser
|
||||
|
|
@ -1902,7 +1902,7 @@
|
|||
These marks show even when Show/Hide ¶ is off. Ces marques s’affichent même lorsque Afficher/Masquer ¶ est désactivé.
|
||||
Typing replaces selected text La frappe remplace le texte sélectionné
|
||||
Update fields before printing Mettre à jour les champs avant l’impression
|
||||
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Refrappe
|
||||
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Remplacement
|
||||
Extend Selection by Units Étendre la sélection par unités
|
||||
Animation Animation
|
||||
Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe)
|
||||
|
|
@ -1929,7 +1929,7 @@
|
|||
Lum: Lum. :
|
||||
Hex: Hex :
|
||||
Document template: Modèle de document :
|
||||
Attach… Attacher…
|
||||
Attach… Joindre…
|
||||
Automatically update document styles Mettre à jour automatiquement les styles du document
|
||||
Organizer… Organiseur…
|
||||
Documents Documents
|
||||
|
|
@ -1968,13 +1968,13 @@
|
|||
Hierarchy Hiérarchie
|
||||
Relationship Relation
|
||||
Pyramid Pyramide
|
||||
Basic List Liste de base
|
||||
Basic Process Processus de base
|
||||
Basic Cycle Cycle de base
|
||||
Basic Pyramid Pyramide de base
|
||||
Basic Radial Radial de base
|
||||
Basic Matrix Matrice de base
|
||||
Basic Venn Venn de base
|
||||
Basic List Liste simple
|
||||
Basic Process Processus simple
|
||||
Basic Cycle Cycle simple
|
||||
Basic Pyramid Pyramide simple
|
||||
Basic Radial Radial simple
|
||||
Basic Matrix Matrice simple
|
||||
Basic Venn Venn simple
|
||||
Shows items of equal weight as blocks in rows. Affiche des éléments de même importance sous forme de blocs en lignes.
|
||||
Shows steps in order, left to right, with arrows between them. Affiche des étapes dans l’ordre, de gauche à droite, reliées par des flèches.
|
||||
Shows steps that repeat, around a circle. Affiche des étapes qui se répètent, disposées en cercle.
|
||||
|
|
@ -2021,14 +2021,14 @@
|
|||
Expression Expression
|
||||
Not Non
|
||||
Num Occurrences Nombre d’occurrences
|
||||
Previous 1 or More 1 occurrence ou plus du précédent
|
||||
Previous 1 or More 1 ou plusieurs du précédent
|
||||
0 or More Characters 0 caractère ou plus
|
||||
Fix Position on Page Fixer la position sur la page
|
||||
Visual effects Effets visuels
|
||||
Animation effects: Effets d’animation :
|
||||
On Activés
|
||||
Off Désactivés
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Désactivés : chaque changement s’affiche immédiatement.
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Avec Désactivés, chaque changement s’affiche immédiatement.
|
||||
Your system has animation effects turned on. Les effets d’animation sont activés sur votre système.
|
||||
Your system has animation effects turned off. Les effets d’animation sont désactivés sur votre système.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Votre système n’indique aucun réglage d’animation, les effets d’animation restent donc activés.
|
||||
|
|
@ -2081,5 +2081,3 @@
|
|||
AutoFit Column by Double-Clicking Ajuster la colonne en double-cliquant
|
||||
Insert Caption… Insérer une légende…
|
||||
Table Properties… Propriétés du tableau…
|
||||
Search Rechercher
|
||||
Title Bar Barre de titre
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -2471,5 +2471,3 @@
|
|||
AutoFit Column by Double-Clicking Oszlop automatikus igazítása dupla kattintással
|
||||
Insert Caption… Felirat beszúrása…
|
||||
Table Properties… Táblázat tulajdonságai…
|
||||
Search Keresés
|
||||
Title Bar Címsor
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -2457,5 +2457,3 @@ preview Title Esempio
|
|||
Correct accidental usage of cAPS LOCK key Correggi l'uso accidentale del tasto bLOC mAIUSC
|
||||
Region: Area geografica:
|
||||
Automatic ({region}) Automatico ({region})
|
||||
Search Cerca
|
||||
Title Bar Barra del titolo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -155,7 +155,6 @@
|
|||
Right (vertical) 右 (縦書き)
|
||||
Ruby text: ルビ文字:
|
||||
Save documents ドキュメントの保存
|
||||
Search 検索
|
||||
Search for templates テンプレートを検索
|
||||
Show all revisions inline すべての変更履歴を本文中に表示
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 表示 › モードの切り替えと同様に、文書の色を反転して表示します。保存、印刷、PDF は変わりません。
|
||||
|
|
@ -199,7 +198,6 @@
|
|||
Page Border ページ罫線の設定
|
||||
Apply to: 適用先:
|
||||
These marks show even when Show/Hide ¶ is off. これらの記号は、[編集記号の表示/非表示 ¶] がオフでも表示されます。
|
||||
Title Bar タイトル バー
|
||||
Typing replaces selected text 入力時に選択文字列を置き換える
|
||||
Update fields before printing 印刷前にフィールドを更新する
|
||||
Use the Insert key to switch overtype on and off Insert キーで上書きのオンとオフを切り替える
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -2114,357 +2114,3 @@
|
|||
Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatycznie ({region})
|
||||
|
||||
# Style, table style, watermark, paper size and equation gallery names, from #750 by @olekkut
|
||||
AaBbCcDd AaBbCcDd
|
||||
Annotation Reference Odwołanie do adnotacji
|
||||
Annotation Subject Temat adnotacji
|
||||
Annotation Text Tekst adnotacji
|
||||
Body text in a short paragraph to show spacing. Treść dokumentu w krótkim akapicie pokazującym odstępy.
|
||||
Default Paragraph Font Domyślna czcionka akapitu
|
||||
Envelope Address Adres na kopercie
|
||||
Envelope Return Adres zwrotny na kopercie
|
||||
Index Heading Nagłówek indeksu
|
||||
Index {n} Indeks {n}
|
||||
Line Number Numer wiersza
|
||||
Normal Normalny
|
||||
Normal Indent Normalny z wcięciem
|
||||
Normal Table Normalna tabela
|
||||
Table Grid Siatka tabeli
|
||||
Table of Figures Spis ilustracji
|
||||
preview Title Tytuł
|
||||
TOA Heading Nagłówek spisu źródeł
|
||||
Good evening Dobry wieczór
|
||||
Good morning Dzień dobry
|
||||
Good afternoon Dzień dobry
|
||||
tOGGLE cASE oDWRÓĆ wIELKOŚĆ lITER
|
||||
Change to Level 1 Zmień na poziom 1
|
||||
Change to Level 2 Zmień na poziom 2
|
||||
Change to Level 3 Zmień na poziom 3
|
||||
Change to Level 4 Zmień na poziom 4
|
||||
Change to Level 5 Zmień na poziom 5
|
||||
Level 1 Poziom 1
|
||||
Level 2 Poziom 2
|
||||
Level 3 Poziom 3
|
||||
ASAP JAK NAJSZYBCIEJ
|
||||
CONFIDENTIAL POUFNE
|
||||
DO NOT COPY NIE KOPIOWAĆ
|
||||
DRAFT WERSJA ROBOCZA
|
||||
SAMPLE WZÓR
|
||||
URGENT PILNE
|
||||
Letter 8.50" × 11.00" Letter 8,50" × 11,00"
|
||||
Legal 8.50" × 14.00" Legal 8,50" × 14,00"
|
||||
Executive 7.25" × 10.50" Executive 7,25" × 10,50"
|
||||
Statement 5.50" × 8.50" Statement 5,50" × 8,50"
|
||||
Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00"
|
||||
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33"
|
||||
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12"
|
||||
Envelope #10 4.12" × 9.50" Koperta nr 10 4,12" × 9,50"
|
||||
Envelope DL 4.33" × 8.66" Koperta DL 4,33" × 8,66"
|
||||
Plain Table 1 Prosta tabela 1
|
||||
Grid Table 1 Light Tabela z siatką 1 — jasna
|
||||
Grid Table 4 – Blue Tabela z siatką 4 — niebieska
|
||||
Grid Table 4 – Green Tabela z siatką 4 — zielona
|
||||
Grid Table 4 – Orange Tabela z siatką 4 — pomarańczowa
|
||||
Grid Table 4 – Purple Tabela z siatką 4 — fioletowa
|
||||
List Table 3 – Blue Tabela listy 3 — niebieska
|
||||
List Table 3 – Green Tabela listy 3 — zielona
|
||||
List Table 3 – Orange Tabela listy 3 — pomarańczowa
|
||||
List Table 3 – Purple Tabela listy 3 — fioletowa
|
||||
Convert between built-up and linear form Konwertuj między postacią profesjonalną a liniową
|
||||
Justification of a display equation Wyrównanie równania wyświetlanego w osobnym wierszu
|
||||
Number the equation; the number sits at the right margin (or type # then the number) Numeruj równanie; numer jest umieszczany przy prawym marginesie (lub wpisz # i numer)
|
||||
Show the equation in the line of text Wyświetl równanie w wierszu tekstu
|
||||
Show the equation on a line of its own Wyświetl równanie w osobnym wierszu
|
||||
Type equations in LaTeX Wpisuj równania w notacji LaTeX
|
||||
Type equations in the linear format (UnicodeMath) Wpisuj równania w formacie liniowym (UnicodeMath)
|
||||
Type normal (non-math) text in the equation Wpisz zwykły (niematematyczny) tekst w równaniu
|
||||
Sides of a right triangle Boki trójkąta prostokątnego
|
||||
Circle area from its radius Pole koła na podstawie promienia
|
||||
Roots of ax²+bx+c Pierwiastki równania ax²+bx+c
|
||||
Binomial expansion of (x+a)ⁿ Rozwinięcie dwumianu (x+a)ⁿ
|
||||
Power series of (1+x)ⁿ Szereg potęgowy (1+x)ⁿ
|
||||
Euler's identity Tożsamość Eulera
|
||||
Derivative as a limit Pochodna jako granica
|
||||
Exponential series Szereg wykładniczy
|
||||
Integral of e^(−x²) Całka z e^(−x²)
|
||||
Sine and cosine series of f(x) Szereg sinusów i cosinusów funkcji f(x)
|
||||
Sum or difference of sines Suma lub różnica sinusów
|
||||
Sum of cosines Suma cosinusów
|
||||
Normal probability density Gęstość rozkładu normalnego
|
||||
Bayes' rule Twierdzenie Bayesa
|
||||
Accents Akcenty
|
||||
Basic Math Podstawowa matematyka
|
||||
Boxed Formulas Wzory w ramce
|
||||
Cases and Stacks Układy klamrowe i piętrowe
|
||||
Common Accent Objects Typowe obiekty z akcentem
|
||||
Common Brackets Typowe nawiasy
|
||||
Common Fractions Typowe ułamki
|
||||
Common Functions Typowe funkcje
|
||||
Common Large Operators Typowe duże operatory
|
||||
Common Operator Structures Typowe struktury operatorów
|
||||
Common Operators Typowe operatory
|
||||
Common Radicals Typowe pierwiastki
|
||||
Common Subscripts and Superscripts Typowe indeksy dolne i górne
|
||||
Contour Integrals Całki okrężne
|
||||
Differentials Różniczki
|
||||
Dots Kropki
|
||||
Empty Matrices Puste macierze
|
||||
Functions Funkcje
|
||||
Geometry Geometria
|
||||
Greek Letters Litery greckie
|
||||
Hyperbolic Functions Funkcje hiperboliczne
|
||||
Identity Matrices Macierze jednostkowe
|
||||
Integrals Całki
|
||||
Inverse Functions Funkcje odwrotne
|
||||
Inverse Hyperbolic Functions Odwrotne funkcje hiperboliczne
|
||||
Letter-Like Symbols Symbole literopodobne
|
||||
Matrices with Brackets Macierze w nawiasach
|
||||
Negated Relations Relacje zanegowane
|
||||
Operator Structures Struktury operatorów
|
||||
Operators Operatory
|
||||
Other Large Operators Inne duże operatory
|
||||
Overbars and Underbars Kreski górne i dolne
|
||||
Products and Coproducts Iloczyny i koprodukty
|
||||
Radicals Pierwiastki
|
||||
Scripts Indeksy
|
||||
Single Brackets Pojedyncze nawiasy
|
||||
Sparse Matrices Macierze rzadkie
|
||||
Subscripts and Superscripts Indeksy dolne i górne
|
||||
Summations Sumy
|
||||
Trigonometric Functions Funkcje trygonometryczne
|
||||
Unions and Intersections Sumy i iloczyny zbiorów
|
||||
1×2 Empty Matrix Pusta macierz 1×2
|
||||
1×3 Empty Matrix Pusta macierz 1×3
|
||||
2×1 Empty Matrix Pusta macierz 2×1
|
||||
2×2 Empty Matrix Pusta macierz 2×2
|
||||
2×3 Empty Matrix Pusta macierz 2×3
|
||||
3×1 Empty Matrix Pusta macierz 3×1
|
||||
3×2 Empty Matrix Pusta macierz 3×2
|
||||
3×3 Empty Matrix Pusta macierz 3×3
|
||||
2×2 Determinant Wyznacznik 2×2
|
||||
2×2 Identity Matrix Macierz jednostkowa 2×2
|
||||
2×2 Identity Matrix (sparse) Macierz jednostkowa 2×2 (rzadka)
|
||||
3×3 Identity Matrix Macierz jednostkowa 3×3
|
||||
3×3 Identity Matrix (sparse) Macierz jednostkowa 3×3 (rzadka)
|
||||
2×2 Matrix in Brackets Macierz 2×2 w nawiasach kwadratowych
|
||||
2×2 Matrix in Double Bars Macierz 2×2 w podwójnych kreskach pionowych
|
||||
2×2 Matrix in Parentheses Macierz 2×2 w nawiasach okrągłych
|
||||
n×n Identity Matrix Macierz jednostkowa n×n
|
||||
n×n Matrix Macierz n×n
|
||||
A xor B A xor B
|
||||
Absolute value cases Przypadki wartości bezwzględnej
|
||||
Acute Akcent ostry
|
||||
Angle Brackets Nawiasy kątowe
|
||||
Angle Brackets with Separator Nawiasy kątowe z separatorem
|
||||
Angle Brackets with Two Separators Nawiasy kątowe z dwoma separatorami
|
||||
Baseline Dots Kropki na linii bazowej
|
||||
Binomial Coefficient Współczynnik dwumianowy
|
||||
Binomial Coefficient (angle) Współczynnik dwumianowy (nawiasy kątowe)
|
||||
Binomial Coefficient with Angle Brackets Współczynnik dwumianowy w nawiasach kątowych
|
||||
Boxed Formula Wzór w ramce
|
||||
Boxed Formula (example) Wzór w ramce (przykład)
|
||||
Braces Nawiasy klamrowe
|
||||
Braces with Separator Nawiasy klamrowe z separatorem
|
||||
Brackets Nawiasy kwadratowe
|
||||
Brackets with Separators Nawiasy kwadratowe z separatorami
|
||||
Breve Brewis
|
||||
Cases (Three Conditions) Układ warunków (trzy warunki)
|
||||
Cases (Two Conditions) Układ warunków (dwa warunki)
|
||||
Ceiling Sufit
|
||||
Change in y over change in x Przyrost y przez przyrost x
|
||||
Check Daszek odwrócony
|
||||
Circled Dot Kropka w okręgu
|
||||
Circled Plus Plus w okręgu
|
||||
Circled Times Znak mnożenia w okręgu
|
||||
Colon Equal Dwukropek i znak równości
|
||||
Contour Integral Całka okrężna
|
||||
Contour Integral with Limits Całka okrężna z granicami
|
||||
Contour Integral with Stacked Limits Całka okrężna z granicami u góry i u dołu
|
||||
Coproduct Koprodukt
|
||||
Coproduct with Limits Koprodukt z granicami
|
||||
Coproduct with Lower Limit Koprodukt z dolną granicą
|
||||
Coproduct with Subscript-Superscript Limits Koprodukt z granicami w indeksach
|
||||
Cosecant Function Funkcja cosecans
|
||||
Cosine 2x Cosinus 2x
|
||||
Cosine Function Funkcja cosinus
|
||||
Cotangent Function Funkcja cotangens
|
||||
Cube Root Pierwiastek sześcienny
|
||||
Delta Equal To Równe z deltą
|
||||
Delta over arrow Delta nad strzałką
|
||||
Diagonal Dots Kropki ukośne
|
||||
Differential Różniczka
|
||||
Differential theta Różniczka theta
|
||||
Differential x Różniczka x
|
||||
Differential y Różniczka y
|
||||
Dot Kropka nad znakiem
|
||||
Double Bar Podwójna kreska nad znakiem
|
||||
Double Dot Dwie kropki nad znakiem
|
||||
Double Integral Całka podwójna
|
||||
Double Integral with Limits Całka podwójna z granicami
|
||||
Double Integral with Stacked Limits Całka podwójna z granicami u góry i u dołu
|
||||
Double Left Arrow with Text Above Podwójna strzałka w lewo z tekstem powyżej
|
||||
Double Left Arrow with Text Below Podwójna strzałka w lewo z tekstem poniżej
|
||||
Double Left Right Arrow with Text Above Podwójna strzałka w lewo i prawo z tekstem powyżej
|
||||
Double Left Right Arrow with Text Below Podwójna strzałka w lewo i prawo z tekstem poniżej
|
||||
Double Right Arrow with Text Above Podwójna strzałka w prawo z tekstem powyżej
|
||||
Double Right Arrow with Text Below Podwójna strzałka w prawo z tekstem poniżej
|
||||
Double Square Brackets Podwójne nawiasy kwadratowe
|
||||
Double Vertical Bars Podwójne kreski pionowe
|
||||
Double summation Suma podwójna
|
||||
Equal Equal Podwójny znak równości
|
||||
Equal by Definition Równe z definicji
|
||||
Exponential Funkcja wykładnicza
|
||||
Floor Podłoga
|
||||
Grave Akcent ciężki
|
||||
Grouping Character Above Znak grupujący powyżej
|
||||
Grouping Character Below Znak grupujący poniżej
|
||||
Hat Daszek
|
||||
Hyperbolic Cosecant Function Funkcja cosecans hiperboliczny
|
||||
Hyperbolic Cosine Function Funkcja cosinus hiperboliczny
|
||||
Hyperbolic Cotangent Function Funkcja cotangens hiperboliczny
|
||||
Hyperbolic Secant Function Funkcja secans hiperboliczny
|
||||
Hyperbolic Sine Function Funkcja sinus hiperboliczny
|
||||
Hyperbolic Tangent Function Funkcja tangens hiperboliczny
|
||||
Hypotenuse Przeciwprostokątna
|
||||
Integral with Limits Całka z granicami
|
||||
Integral with Stacked Limits Całka z granicami u góry i u dołu
|
||||
Intersection Iloczyn zbiorów
|
||||
Intersection with Limits Iloczyn zbiorów z granicami
|
||||
Intersection with Lower Limit Iloczyn zbiorów z dolną granicą
|
||||
Intersection with Subscript-Superscript Limits Iloczyn zbiorów z granicami w indeksach
|
||||
Inverse Cosecant Function Funkcja arcus cosecans
|
||||
Inverse Cosine Function Funkcja arcus cosinus
|
||||
Inverse Cotangent Function Funkcja arcus cotangens
|
||||
Inverse Hyperbolic Cosecant Function Funkcja area cosecans hiperboliczny
|
||||
Inverse Hyperbolic Cosine Function Funkcja area cosinus hiperboliczny
|
||||
Inverse Hyperbolic Cotangent Function Funkcja area cotangens hiperboliczny
|
||||
Inverse Hyperbolic Secant Function Funkcja area secans hiperboliczny
|
||||
Inverse Hyperbolic Sine Function Funkcja area sinus hiperboliczny
|
||||
Inverse Hyperbolic Tangent Function Funkcja area tangens hiperboliczny
|
||||
Inverse Secant Function Funkcja arcus secans
|
||||
Inverse Sine Function Funkcja arcus sinus
|
||||
Inverse Tangent Function Funkcja arcus tangens
|
||||
Left Arrow with Text Above Strzałka w lewo z tekstem powyżej
|
||||
Left Arrow with Text Below Strzałka w lewo z tekstem poniżej
|
||||
Left Bracket Twice Podwójny lewy nawias kwadratowy
|
||||
Left Right Arrow Above Strzałka w lewo i prawo powyżej
|
||||
Left Right Arrow with Text Above Strzałka w lewo i prawo z tekstem powyżej
|
||||
Left Right Arrow with Text Below Strzałka w lewo i prawo z tekstem poniżej
|
||||
Left Subscript-Superscript Lewy indeks dolny i górny
|
||||
Leftwards Arrow Above Strzałka w lewo powyżej
|
||||
Leftwards Harpoon Above Harpun w lewo powyżej
|
||||
Limit Granica
|
||||
Limit Example Przykład granicy
|
||||
Linear Fraction Ułamek liniowy
|
||||
Logarithm Logarytm
|
||||
Logarithm with Base Logarytm o podanej podstawie
|
||||
Logical AND Koniunkcja logiczna
|
||||
Logical AND with Limits Koniunkcja logiczna z granicami
|
||||
Logical OR Alternatywa logiczna
|
||||
Logical OR with Limits Alternatywa logiczna z granicami
|
||||
Maximum Maksimum
|
||||
Maximum Example Przykład maksimum
|
||||
Measured By Mierzone przez
|
||||
Midline Dots Kropki środkowe
|
||||
Minimum Minimum
|
||||
Minus Equal Minus i znak równości
|
||||
Natural Logarithm Logarytm naturalny
|
||||
One half Jedna druga
|
||||
Outward Brackets Nawiasy kwadratowe skierowane na zewnątrz
|
||||
Overbar Kreska górna
|
||||
Overbrace Klamra górna
|
||||
Parentheses Nawiasy okrągłe
|
||||
Parentheses with Separator Nawiasy okrągłe z separatorem
|
||||
Partial Differential Różniczka cząstkowa
|
||||
Pi Over 2 Pi przez 2
|
||||
Plus Equal Plus i znak równości
|
||||
Product Iloczyn
|
||||
Product of A Iloczyn A
|
||||
Product with Limits Iloczyn z granicami
|
||||
Product with Lower Limit Iloczyn z dolną granicą
|
||||
Product with Subscript-Superscript Limits Iloczyn z granicami w indeksach
|
||||
Quadratic Root Pierwiastek równania kwadratowego
|
||||
Radical with Degree Pierwiastek ze stopniem
|
||||
Right Arrow with Text Above Strzałka w prawo z tekstem powyżej
|
||||
Right Arrow with Text Below Strzałka w prawo z tekstem poniżej
|
||||
Right Bracket Twice Podwójny prawy nawias kwadratowy
|
||||
Rightwards Arrow Above Strzałka w prawo powyżej
|
||||
Rightwards Harpoon Above Harpun w prawo powyżej
|
||||
Secant Function Funkcja secans
|
||||
Sine Function Funkcja sinus
|
||||
Sine theta Sinus theta
|
||||
Single Left Angle Pojedynczy lewy nawias kątowy
|
||||
Single Left Bar Pojedyncza lewa kreska pionowa
|
||||
Single Left Brace Pojedynczy lewy nawias klamrowy
|
||||
Single Left Bracket Pojedynczy lewy nawias kwadratowy
|
||||
Single Left Parenthesis Pojedynczy lewy nawias okrągły
|
||||
Single Right Brace Pojedynczy prawy nawias klamrowy
|
||||
Single Right Bracket Pojedynczy prawy nawias kwadratowy
|
||||
Single Right Parenthesis Pojedynczy prawy nawias okrągły
|
||||
Skewed Fraction Ułamek ukośny
|
||||
Small change Mały przyrost
|
||||
Square Root Pierwiastek kwadratowy
|
||||
Square Root with Degree Pierwiastek dowolnego stopnia
|
||||
Square Union Kwadratowa suma zbiorów
|
||||
Stack Object Obiekt piętrowy
|
||||
Stack Object (Three) Obiekt piętrowy (trzy poziomy)
|
||||
Stacked Fraction Ułamek piętrowy
|
||||
Stacked, no bar Układ piętrowy bez kreski
|
||||
Subscript-Superscript Indeks dolny i górny
|
||||
Summation Suma
|
||||
Summation from 0 to n Suma od 0 do n
|
||||
Summation over binomials Suma po dwumianach
|
||||
Summation with Limits Suma z granicami
|
||||
Summation with Lower Limit Suma z dolną granicą
|
||||
Summation with Subscript Limit Suma z granicą w indeksie dolnym
|
||||
Summation with Subscript-Superscript Limits Suma z granicami w indeksach
|
||||
Surface Integral Całka powierzchniowa
|
||||
Surface Integral with Limits Całka powierzchniowa z granicami
|
||||
Surface Integral with Stacked Limits Całka powierzchniowa z granicami u góry i u dołu
|
||||
Tangent Function Funkcja tangens
|
||||
Tangent formula Wzór na tangens
|
||||
Text over Colon Equal Tekst nad znakiem przypisania
|
||||
Text over Equal Tekst nad znakiem równości
|
||||
Tilde Tylda
|
||||
Triple Dot Trzy kropki nad znakiem
|
||||
Triple Integral Całka potrójna
|
||||
Triple Integral with Limits Całka potrójna z granicami
|
||||
Triple Integral with Stacked Limits Całka potrójna z granicami u góry i u dołu
|
||||
Underbar Kreska dolna
|
||||
Underbrace Klamra dolna
|
||||
Union Suma zbiorów
|
||||
Union of intersections Suma iloczynów zbiorów
|
||||
Union with Limits Suma zbiorów z granicami
|
||||
Union with Lower Limit Suma zbiorów z dolną granicą
|
||||
Union with Plus Suma zbiorów z plusem
|
||||
Union with Subscript-Superscript Limits Suma zbiorów z granicami w indeksach
|
||||
Up Diagonal Dots Kropki ukośne w górę
|
||||
Vector A Wektor A
|
||||
Vector AB Wektor AB
|
||||
Vertical Bars Kreski pionowe
|
||||
Vertical Dots Kropki pionowe
|
||||
Volume Integral Całka objętościowa
|
||||
Volume Integral with Limits Całka objętościowa z granicami
|
||||
Volume Integral with Stacked Limits Całka objętościowa z granicami u góry i u dołu
|
||||
Yields Daje w wyniku
|
||||
x bar x z kreską
|
||||
All - Linear Wszystkie — liniowe
|
||||
All - Professional Wszystkie — profesjonalne
|
||||
Current - Linear Bieżące — liniowe
|
||||
Current - Professional Bieżące — profesjonalne
|
||||
Centered as Group Wyśrodkowane jako grupa
|
||||
✓ None ✓ Brak
|
||||
Change to Level 6 Zmień na poziom 6
|
||||
Change to Level 7 Zmień na poziom 7
|
||||
Change to Level 8 Zmień na poziom 8
|
||||
Change to Level 9 Zmień na poziom 9
|
||||
Level 4 Poziom 4
|
||||
Level 5 Poziom 5
|
||||
Level 6 Poziom 6
|
||||
Level 7 Poziom 7
|
||||
Level 8 Poziom 8
|
||||
Level 9 Poziom 9
|
||||
Typing Pisanie
|
||||
Search Wyszukaj
|
||||
Title Bar Pasek tytułu
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -162,7 +162,6 @@
|
|||
Border Painter Pincel de bordas
|
||||
Border\nPainter Pincel\nde bordas
|
||||
Borders Bordas
|
||||
Search Pesquisar
|
||||
Show the mini toolbar when text is selected Mostrar a minibarra de ferramentas ao selecionar texto
|
||||
Spaces Espaços
|
||||
Spelling Ortografia
|
||||
|
|
@ -179,7 +178,6 @@
|
|||
Page Border Borda da página
|
||||
Apply to: Aplicar a:
|
||||
These marks show even when Show/Hide ¶ is off. Estas marcas aparecem mesmo com Mostrar/ocultar ¶ desativado.
|
||||
Title Bar Barra de título
|
||||
Typing replaces selected text O que você digita substitui o texto selecionado
|
||||
Update fields before printing Atualizar campos antes de imprimir
|
||||
Use the Insert key to switch overtype on and off Usar a tecla Insert para ativar e desativar a sobrescrita
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -189,8 +189,6 @@
|
|||
Delete Style Удалить стиль
|
||||
Delete Table Удалить таблицу
|
||||
Design Дизайн
|
||||
Search Поиск
|
||||
Title Bar Строка заголовка
|
||||
development build сборка для разработки
|
||||
Diagonal Диагональ
|
||||
Diamond Ромб
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -204,7 +204,6 @@
|
|||
Preview Pregled
|
||||
Remove Password Ukloni lozinku
|
||||
Save documents Čuvanje dokumenata
|
||||
Search Pretraga
|
||||
Shadow, glow and soft edges Senka, sjaj i meke ivice
|
||||
Shape Effects Efekti oblika
|
||||
Show all revisions inline Prikaži sve izmene u tekstu
|
||||
|
|
@ -256,7 +255,6 @@
|
|||
Page Border Ivica strane
|
||||
Apply to: Primeni na:
|
||||
These marks show even when Show/Hide ¶ is off. Ove oznake se prikazuju i kada je Prikaži/sakrij ¶ isključeno.
|
||||
Title Bar Naslovna traka
|
||||
Typing replaces selected text Otkucano zamenjuje izabrani tekst
|
||||
Update fields before printing Ažuriraj polja pre štampanja
|
||||
Use the Insert key to switch overtype on and off Taster Insert uključuje i isključuje prepisivanje
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -208,7 +208,6 @@
|
|||
Preview Преглед
|
||||
Remove Password Уклони лозинку
|
||||
Save documents Чување докумената
|
||||
Search Претрага
|
||||
Shadow, glow and soft edges Сенка, сјај и меке ивице
|
||||
Shape Effects Ефекти облика
|
||||
Show all revisions inline Прикажи све измене у тексту
|
||||
|
|
@ -260,7 +259,6 @@
|
|||
Page Border Ивица стране
|
||||
Apply to: Примени на:
|
||||
These marks show even when Show/Hide ¶ is off. Ове ознаке се приказују и када је Прикажи/сакриј ¶ искључено.
|
||||
Title Bar Насловна трака
|
||||
Typing replaces selected text Откуцано замењује изабрани текст
|
||||
Update fields before printing Ажурирај поља пре штампања
|
||||
Use the Insert key to switch overtype on and off Тастер Insert укључује и искључује преписивање
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -922,11 +922,10 @@ fn polish_covers_every_key_of_every_other_catalog() {
|
|||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let polish = keys(lang("pl").0.source);
|
||||
// Italian and Hungarian still carry "Style: APA", which the interface no longer shows;
|
||||
// Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line
|
||||
// pieces of gallery labels. Polish matches every other catalog, including the style names and
|
||||
// equation gallery Russian translates.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg")) {
|
||||
// Russian, Italian, Hungarian, Turkish, Bulgarian and Spanish also translate the built-in
|
||||
// style names and the equation gallery, which the others don't yet; Polish matches every
|
||||
// other catalog.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr" | "bg" | "es")) {
|
||||
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());
|
||||
|
|
|
|||
|
|
@ -622,7 +622,6 @@
|
|||
Save As Farklı Kaydet
|
||||
Save as Template Şablon Olarak Kaydet
|
||||
Saved Kaydedildi
|
||||
Search Ara
|
||||
Search Commands Komut Ara
|
||||
Search commands and help Komutlarda ve yardımda ara
|
||||
Search document Belgede ara
|
||||
|
|
@ -770,7 +769,6 @@ preview Title Örnek
|
|||
TOA Heading Yetki Tablosu Başlığı
|
||||
TOC Heading İçindekiler Başlığı
|
||||
TOC {n} İçindekiler {n}
|
||||
Title Bar Başlık Çubuğu
|
||||
Top Border Üst Kenarlık
|
||||
Top of Page Sayfa Üstü
|
||||
Top: Üst:
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 15.
|
|
|
@ -164,7 +164,6 @@
|
|||
Border Painter Пензель меж
|
||||
Border\nPainter Пензель\nмеж
|
||||
Borders Межі
|
||||
Search Пошук
|
||||
Show the mini toolbar when text is selected Показувати міні-панель інструментів під час виділення тексту
|
||||
Spaces Пробіли
|
||||
Spelling Правопис
|
||||
|
|
@ -181,7 +180,6 @@
|
|||
Page Border Межа сторінки
|
||||
Apply to: Застосувати до:
|
||||
These marks show even when Show/Hide ¶ is off. Ці знаки відображаються, навіть коли «Показати/приховати ¶» вимкнено.
|
||||
Title Bar Рядок заголовка
|
||||
Typing replaces selected text Введення замінює виділений текст
|
||||
Update fields before printing Оновлювати поля перед друком
|
||||
Use the Insert key to switch overtype on and off Клавіша Insert вмикає та вимикає режим заміни
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -167,7 +167,6 @@
|
|||
Right (vertical) 右对齐(竖排)
|
||||
Ruby text: 拼音文字:
|
||||
Save documents 保存文档
|
||||
Search 搜索
|
||||
Search for templates 搜索模板
|
||||
Show all revisions inline 以嵌入方式显示所有修订
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像“视图 › 切换模式”一样以反转的颜色显示文档。保存、打印和 PDF 不受影响。
|
||||
|
|
@ -211,7 +210,6 @@
|
|||
Page Border 页面边框线
|
||||
Apply to: 应用于:
|
||||
These marks show even when Show/Hide ¶ is off. 即使关闭“显示/隐藏 ¶”,这些标记也会显示。
|
||||
Title Bar 标题栏
|
||||
Typing replaces selected text 键入内容替换所选文字
|
||||
Update fields before printing 打印前更新域
|
||||
Use the Insert key to switch overtype on and off 用 Insert 键切换改写模式
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -156,7 +156,6 @@
|
|||
Right (vertical) 靠右(直書)
|
||||
Ruby text: 注音文字:
|
||||
Save documents 儲存文件
|
||||
Search 搜尋
|
||||
Search for templates 搜尋範本
|
||||
Show all revisions inline 以內嵌方式顯示所有修訂
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像「檢視 › 切換模式」一樣以反轉的色彩顯示文件。儲存、列印和 PDF 不受影響。
|
||||
|
|
@ -200,7 +199,6 @@
|
|||
Page Border 頁面框線設定
|
||||
Apply to: 套用至:
|
||||
These marks show even when Show/Hide ¶ is off. 即使關閉「顯示/隱藏 ¶」,這些標記也會顯示。
|
||||
Title Bar 標題列
|
||||
Typing replaces selected text 輸入的內容取代選取的文字
|
||||
Update fields before printing 列印前更新功能變數
|
||||
Use the Insert key to switch overtype on and off 使用 Insert 鍵切換取代模式
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -376,12 +376,6 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a key runs its shortcut with the canvas unfocused (after a ribbon click, say): Mod and
|
||||
/// Alt shortcuts (Alt+Q reaches Search from anywhere, as in Word, #819) and the function keys.
|
||||
fn is_global_shortcut(key: Key, m: Modifiers) -> bool {
|
||||
m.command || m.alt || is_function_key(key)
|
||||
}
|
||||
|
||||
/// Command shortcuts when the canvas isn't focused but no text field is either.
|
||||
pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
|
||||
// Customize Keyboard records the keys pressed; they never run commands meanwhile.
|
||||
|
|
@ -391,7 +385,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
|
|||
let events = ctx.input(|i| i.events.clone());
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e
|
||||
&& is_global_shortcut(key, modifiers)
|
||||
&& (modifiers.command || is_function_key(key) || ui_shortcut(key, modifiers).is_some())
|
||||
{
|
||||
dispatch(app, key, modifiers);
|
||||
}
|
||||
|
|
@ -444,15 +438,4 @@ mod tests {
|
|||
assert!(!is_context_menu_key(Key::F10, m(true, true, false)), "Ctrl+Shift+F10");
|
||||
assert!(!is_context_menu_key(Key::F9, m(true, false, false)), "Shift+F9 is a field key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_shortcuts_work_without_the_canvas_focused() {
|
||||
let m = |command, alt, shift| egui::Modifiers { command, alt, shift, ..Default::default() };
|
||||
assert!(is_global_shortcut(Key::Q, m(false, true, false)), "Alt+Q (#819)");
|
||||
assert!(is_global_shortcut(Key::D, m(false, true, true)), "Alt+Shift+D");
|
||||
assert!(is_global_shortcut(Key::B, m(true, false, false)), "Mod+B");
|
||||
assert!(is_global_shortcut(Key::F7, m(false, false, false)), "F7");
|
||||
assert!(!is_global_shortcut(Key::Q, m(false, false, false)), "a plain letter is typing");
|
||||
assert!(!is_global_shortcut(Key::Q, m(false, false, true)), "Shift+letter is typing");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,9 +81,6 @@ const TAB_LETTERS: [(&str, &str); 11] = [
|
|||
("Help", "Y"),
|
||||
];
|
||||
|
||||
/// The Search box's badge beside the tab badges (Word's Alt, Q).
|
||||
pub const SEARCH_LETTER: &str = "Q";
|
||||
|
||||
/// The letter badge for a tab (empty when it has none).
|
||||
pub fn tab_letter(tab: &str) -> &'static str {
|
||||
TAB_LETTERS.iter().find(|(t, _)| *t == tab).map(|(_, l)| *l).unwrap_or("")
|
||||
|
|
@ -172,7 +169,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
|||
m("Line and Paragraph Spacing", "para.lineSpacing"),
|
||||
m("Shading", "para.shading"),
|
||||
m("Borders", "para.borders"),
|
||||
c("Find", "edit.find"),
|
||||
d("Find", "find"),
|
||||
d("Replace", "replace"),
|
||||
m("Select", "select.all"),
|
||||
c("Dictate", "tools.dictate"),
|
||||
|
|
@ -546,11 +543,6 @@ pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool {
|
|||
// File opens the Backstage: no ribbon commands to badge.
|
||||
app.ui.keytips = if tab == "File" { Phase::Off } else { Phase::Commands };
|
||||
}
|
||||
// Q is the title bar's Search box, as in Word (#819).
|
||||
None if name == SEARCH_LETTER => {
|
||||
app.ui.keytips = Phase::Off;
|
||||
let _ = app.run("tools.search", serde_json::json!({}));
|
||||
}
|
||||
None => app.ui.keytips = Phase::Off,
|
||||
}
|
||||
}
|
||||
|
|
@ -1126,17 +1118,4 @@ mod tests {
|
|||
b.apply_prefs(serde_json::from_str("{}").unwrap());
|
||||
assert!(b.ui.keytips_enabled, "older preferences retain enabled KeyTips");
|
||||
}
|
||||
|
||||
/// Alt, then Q opens Search like Alt+Q (#819); no tab owns Q.
|
||||
#[test]
|
||||
fn alt_then_q_opens_search() {
|
||||
let mut a = app();
|
||||
a.ui.backstage = false;
|
||||
a.dialog = None;
|
||||
assert!(TAB_LETTERS.iter().all(|(_, l)| *l != SEARCH_LETTER));
|
||||
activate(&mut a);
|
||||
assert!(handle_key(&mut a, egui::Key::Q));
|
||||
assert_eq!(a.ui.keytips, Phase::Off);
|
||||
assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::Commands { .. })), "{:?}", a.dialog.as_ref().map(|d| d.name()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ pub mod system_clipboard;
|
|||
pub mod table_mouse;
|
||||
pub mod table_pen;
|
||||
pub mod table_resize;
|
||||
pub mod table_ruler;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod window_geometry;
|
||||
|
|
@ -377,8 +376,6 @@ pub struct WordApp {
|
|||
pub(crate) synthetic: Vec<egui::Event>,
|
||||
/// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`).
|
||||
pub(crate) keytip_rects: Vec<(egui::Rect, String)>,
|
||||
/// The Navigation pane's search box takes the keyboard on its next frame (Ctrl+F, #712).
|
||||
pub(crate) nav_search_focus: bool,
|
||||
styled: bool,
|
||||
/// The CJK face order the installed UI fonts use (Chinese first, or Japanese first).
|
||||
fonts_hans: bool,
|
||||
|
|
@ -482,7 +479,6 @@ impl WordApp {
|
|||
pending_shots: Vec::new(),
|
||||
synthetic: Vec::new(),
|
||||
keytip_rects: Vec::new(),
|
||||
nav_search_focus: false,
|
||||
styled: false,
|
||||
fonts_hans: false,
|
||||
fonts_system_cjk: false,
|
||||
|
|
@ -859,8 +855,7 @@ impl WordApp {
|
|||
if id == "view.readMode" {
|
||||
self.canvas.read_first_visible = None;
|
||||
}
|
||||
// Drawing leaves the caret where it was: following it would pull the view off the ink.
|
||||
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") {
|
||||
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") {
|
||||
self.canvas.scroll_to_caret = true;
|
||||
}
|
||||
for req in std::mem::take(&mut self.session.ui_requests) {
|
||||
|
|
@ -883,10 +878,6 @@ impl WordApp {
|
|||
|
||||
/// Requests commands make of the UI (open a dialog…).
|
||||
fn handle_request(&mut self, req: &Value) {
|
||||
// Ctrl+F: type straight into the Navigation pane's search box (#712).
|
||||
if req.get("focus").and_then(Value::as_str) == Some("navSearch") {
|
||||
self.nav_search_focus = true;
|
||||
}
|
||||
if let Some(what) = req.get("open").and_then(Value::as_str) {
|
||||
match what {
|
||||
"openFile" => self.open_dialog(),
|
||||
|
|
@ -1382,7 +1373,7 @@ impl WordApp {
|
|||
{
|
||||
let path = f.path().to_string_lossy().to_string();
|
||||
let lp = path.to_ascii_lowercase();
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e));
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e));
|
||||
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e));
|
||||
let _ = if img {
|
||||
self.run("insert.picture", json!({"path": path}))
|
||||
|
|
@ -1554,7 +1545,7 @@ impl WordApp {
|
|||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
for (name, bytes) in files {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lower.ends_with(e));
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
|
||||
// Any recipient file ends the pending picker request, so a later .txt drop opens normally.
|
||||
let asked = std::mem::take(&mut self.recipient_list_pending);
|
||||
let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && asked);
|
||||
|
|
@ -1795,9 +1786,6 @@ pub fn now_ms() -> f64 {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_perf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -2029,9 +2017,7 @@ mod tests {
|
|||
assert_eq!((style.as_str(), shape.as_str()), ("enlarge", "square"));
|
||||
}
|
||||
|
||||
/// Issue #772: the Find and Replace dialog left focus on the document, so typing didn't go into
|
||||
/// Find what. (Ctrl/⌘+F now searches in the Navigation pane, as in Word (#712), so this opens
|
||||
/// the dialog with Ctrl/⌘+H.)
|
||||
/// Issue #772: Ctrl/⌘+F left focus on the document, so typing didn't go into Find what.
|
||||
#[test]
|
||||
fn find_shortcut_focuses_find_what() {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
@ -2048,7 +2034,7 @@ mod tests {
|
|||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let key = egui::Event::Key { key: egui::Key::H, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
let key = egui::Event::Key { key: egui::Key::F, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
frame(&mut a, vec![key]);
|
||||
for _ in 0..2 {
|
||||
frame(&mut a, Vec::new());
|
||||
|
|
@ -2838,59 +2824,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// #861: the save prompt answers from the keyboard alone. Save has focus when it opens, and
|
||||
/// Left/Right move the focus between Save, Don't Save and Cancel (stopping at the ends).
|
||||
#[test]
|
||||
fn arrow_keys_choose_the_save_prompt_answer() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for (arrows, focused, quits, saves) in [
|
||||
(vec![egui::Key::ArrowLeft], "Save", true, true),
|
||||
(vec![egui::Key::ArrowRight], "Don't Save", true, false),
|
||||
(vec![egui::Key::ArrowRight, egui::Key::ArrowRight, egui::Key::ArrowRight], "Cancel", false, false),
|
||||
(vec![egui::Key::ArrowRight, egui::Key::ArrowRight, egui::Key::ArrowLeft], "Don't Save", true, false),
|
||||
] {
|
||||
let dir = scratch(&format!("arrows-{}-{}", arrows.len(), focused.len()));
|
||||
let path = docx_on_disk(&dir, "Draft");
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let mut a = app();
|
||||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Revised "})).unwrap();
|
||||
a.run("file.close", json!({})).unwrap();
|
||||
assert_eq!(prompt(&a), Some("saveChanges"));
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().build_ui_state(
|
||||
move |ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if fonts {
|
||||
dialogs::show(app, &ctx);
|
||||
} else {
|
||||
theme::install_fonts_for(&ctx, false);
|
||||
fonts = true;
|
||||
}
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.get_by_label("Save").is_focused(), "Save has focus when the prompt opens");
|
||||
for key in &arrows {
|
||||
h.key_press(*key);
|
||||
h.step();
|
||||
}
|
||||
assert!(h.get_by_label(focused).is_focused(), "{arrows:?}: {focused} has focus");
|
||||
h.key_press(egui::Key::Enter);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let a = h.state();
|
||||
assert_eq!(prompt(a), None, "{arrows:?}: answered");
|
||||
assert_eq!(a.quit_requested, quits, "{arrows:?}: quit");
|
||||
assert_eq!(std::fs::read(&path).unwrap() != before, saves, "{arrows:?}: file written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.print` hands the document's PDF to the host's print hook, and is an error (with
|
||||
/// nothing printed) when the host has none.
|
||||
#[test]
|
||||
|
|
@ -3182,11 +3115,10 @@ mod tests {
|
|||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns");
|
||||
}
|
||||
|
||||
/// #819: Alt+Q opens Search with its box focused, as in Word: the Q isn't typed into the
|
||||
/// document, and what is typed next goes into the search box.
|
||||
/// #819: Alt+Q moves to "Tell me" command search, as in Word for Windows, without typing a q.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn alt_q_opens_search_and_typing_goes_there() {
|
||||
fn alt_q_opens_command_search_from_the_page() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
|
@ -3198,64 +3130,13 @@ mod tests {
|
|||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.event(egui::Event::Text("Kilns".into()));
|
||||
h.step();
|
||||
// Some platforms send the key's text with an Alt shortcut; it isn't typed.
|
||||
h.event(key(egui::Key::Q, egui::Modifiers::ALT));
|
||||
h.event(egui::Event::Key { key: egui::Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT });
|
||||
h.event(egui::Event::Text("q".into()));
|
||||
h.step();
|
||||
let query = |h: &egui_kittest::Harness<WordApp>| match &h.state().dialog {
|
||||
Some(dialogs::Dialog::Commands { query, .. }) => Some(query.clone()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(query(&h).as_deref(), Some(""), "Search is open and empty");
|
||||
h.step();
|
||||
{
|
||||
use egui_kittest::kittest::Queryable;
|
||||
assert!(h.query_by_label_contains("Word Count").is_some(), "an empty box suggests commands, as Word's does");
|
||||
}
|
||||
h.event(egui::Event::Text("bold".into()));
|
||||
h.step();
|
||||
assert_eq!(query(&h).as_deref(), Some("bold"), "typing goes into the search box");
|
||||
assert!(matches!(h.state().dialog, Some(dialogs::Dialog::Commands { .. })), "command search is open");
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns");
|
||||
h.event(key(egui::Key::Escape, egui::Modifiers::NONE));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.state().dialog.is_none(), "Escape closes Search");
|
||||
// Words that name no command: Enter finds them in the document, in the Navigation pane.
|
||||
h.event(key(egui::Key::Q, egui::Modifiers::ALT));
|
||||
h.step();
|
||||
h.step();
|
||||
// Typing sends each key's press before its text.
|
||||
h.event(key(egui::Key::K, egui::Modifiers::SHIFT));
|
||||
h.event(egui::Event::Text("Kilns".into()));
|
||||
h.step();
|
||||
h.event(key(egui::Key::Enter, egui::Modifiers::NONE));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.state().dialog.is_none(), "Enter ran the search");
|
||||
assert!(h.state().session.view.nav_pane, "the Navigation pane shows the results");
|
||||
assert_eq!(h.state().session.find.results.len(), 1);
|
||||
}
|
||||
|
||||
/// #819: `tools.search` with a query lists matching commands for scripts and agents; every
|
||||
/// word has to match, so "insert table" finds the command that inserts a table.
|
||||
#[test]
|
||||
fn search_command_matches_every_word() {
|
||||
let mut a = app();
|
||||
let r = a.run("tools.search", json!({"query": "insert table"})).unwrap();
|
||||
let ids: Vec<&str> = r["matches"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect();
|
||||
assert!(ids.contains(&"insert.table"), "{ids:?}");
|
||||
assert!(r["matches"].as_array().unwrap().iter().all(|m| {
|
||||
let hay = format!("{} {} {}", m["label"], m["location"], m["id"]).to_lowercase();
|
||||
hay.contains("insert") && hay.contains("table")
|
||||
}));
|
||||
assert_eq!(a.run("tools.search", json!({"query": " "})).unwrap()["matches"], json!([]));
|
||||
assert_eq!(a.run("tools.search", json!({"query": "zzqqxx"})).unwrap()["matches"], json!([]));
|
||||
assert!(a.dialog.is_none(), "a query never opens the box");
|
||||
a.run("tools.search", json!({})).unwrap();
|
||||
assert!(matches!(a.dialog, Some(dialogs::Dialog::Commands { .. })), "no query opens Search");
|
||||
}
|
||||
|
||||
/// Save writes the document first, then carries on with what the user asked for.
|
||||
|
|
@ -4176,27 +4057,6 @@ mod tests {
|
|||
assert!(!a.recipient_list_pending);
|
||||
}
|
||||
|
||||
/// Issue #859: lifting the pen jumped the view back to the caret, usually on page 1. Drawing
|
||||
/// leaves the caret where it was, so the view stays on the ink; typing still follows the caret.
|
||||
#[test]
|
||||
fn drawing_ink_keeps_the_view_where_it_is() {
|
||||
let mut a = typed();
|
||||
let keeps_view = |a: &mut WordApp, id: &str, params: Value| {
|
||||
a.canvas.scroll_to_caret = false;
|
||||
let r = a.run(id, params);
|
||||
assert!(r.is_ok(), "{id}: {r:?}");
|
||||
assert!(!a.canvas.scroll_to_caret, "{id} scrolled to the caret");
|
||||
};
|
||||
keeps_view(&mut a, "draw.stroke", json!({"page": 0, "points": [[100, 300], [200, 320]], "tool": "pen"}));
|
||||
keeps_view(&mut a, "draw.lasso", json!({"page": 0, "points": [[80, 280], [220, 280], [220, 340], [80, 340]]}));
|
||||
keeps_view(&mut a, "draw.lassoMove", json!({"dx": 10, "dy": 10}));
|
||||
keeps_view(&mut a, "draw.stroke", json!({"page": 0, "points": [[300, 500], [400, 500]], "tool": "pencil"}));
|
||||
keeps_view(&mut a, "draw.erase", json!({"page": 0, "x": 350, "y": 500}));
|
||||
a.canvas.scroll_to_caret = false;
|
||||
a.run("text.insert", json!({"text": "x"})).unwrap();
|
||||
assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view");
|
||||
}
|
||||
|
||||
/// `ui.render` and `ui.screenshot` without a `path` return the PNG in the reply instead of
|
||||
/// writing a fixed name into the shared temporary folder, where another user could have
|
||||
/// planted a link to one of this user's files.
|
||||
|
|
@ -4264,63 +4124,4 @@ mod tests {
|
|||
assert!(h.state().canvas.context_menu_open);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire");
|
||||
}
|
||||
|
||||
/// #712: Find, Replace and Go To open as in Word. Ctrl+F puts the keyboard in the Navigation
|
||||
/// pane's search box (with the selected text in it), not a dialog; Ctrl+H and Ctrl+G / F5 open
|
||||
/// the one Find and Replace dialog on its Replace and Go To tabs.
|
||||
#[test]
|
||||
fn find_replace_and_go_to_keys_like_word() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app(),
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into()));
|
||||
h.step();
|
||||
let nav_query = |h: &egui_kittest::Harness<WordApp>| h.ctx.data(|d| d.get_temp::<String>(egui::Id::new("nav_query"))).unwrap_or_default();
|
||||
// Ctrl+F: the pane, and typing goes into its search box.
|
||||
h.event(key(egui::Key::F, egui::Modifiers::COMMAND));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.state().session.view.nav_pane, "the Navigation pane opened");
|
||||
assert!(h.state().dialog.is_none(), "no dialog: {:?}", h.state().dialog.as_ref().map(|d| d.name()));
|
||||
h.event(key(egui::Key::C, egui::Modifiers::NONE));
|
||||
h.event(egui::Event::Text("cool".into()));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(nav_query(&h), "cool", "typed into the pane's search box");
|
||||
assert_eq!(h.state().session.find.results.len(), 1);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire clay. Kilns cool.");
|
||||
// With text selected, Ctrl+F searches for it.
|
||||
h.state_mut().run("select.text", json!({"text": "Kilns"})).unwrap();
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
h.step();
|
||||
h.event(key(egui::Key::F, egui::Modifiers::COMMAND));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(nav_query(&h), "Kilns");
|
||||
assert_eq!(h.state().session.find.results.len(), 2);
|
||||
// Ctrl+H: the Replace tab; Go To: the Go To tab of the same dialog.
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
h.event(key(egui::Key::Escape, egui::Modifiers::NONE));
|
||||
h.step();
|
||||
h.step();
|
||||
h.event(key(egui::Key::H, egui::Modifiers::COMMAND));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("replace"));
|
||||
h.state_mut().run("edit.goto", json!({})).unwrap();
|
||||
h.step();
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("goto"));
|
||||
h.state_mut().run("edit.advancedFind", json!({})).unwrap();
|
||||
h.step();
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("find"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,22 +111,8 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
let qid = egui::Id::new("nav_query");
|
||||
let mut q = ui.data(|d| d.get_temp::<String>(qid)).unwrap_or_default();
|
||||
// Ctrl+F with text selected searches for it, as Word does (a selection within one line).
|
||||
let focus = std::mem::take(&mut app.nav_search_focus);
|
||||
let picked = focus.then(|| app.session.selected_text()).filter(|t| !t.is_empty() && !t.contains(['\n', '\r']) && t.chars().count() <= 255);
|
||||
if let Some(t) = &picked {
|
||||
q = t.clone();
|
||||
}
|
||||
let mut out = egui::TextEdit::singleline(&mut q).hint_text(tl!("Search document")).desired_width(f32::INFINITY).show(ui);
|
||||
let r = out.response.clone();
|
||||
if focus {
|
||||
// Focused with its text selected, so typing replaces it.
|
||||
r.request_focus();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(q.chars().count()));
|
||||
out.state.cursor.set_char_range(Some(all));
|
||||
out.state.store(ui.ctx(), r.id);
|
||||
}
|
||||
if r.changed() || picked.is_some() {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search document")).desired_width(f32::INFINITY));
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(qid, q.clone()));
|
||||
if !q.is_empty() {
|
||||
let _ = app.session.run("edit.find", &json!({"text": q}));
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub const MAX_PIXELS: usize = 50_000_000;
|
|||
pub const MAX_FILE_BYTES: u64 = 200 << 20;
|
||||
/// Most picture files one paste inserts.
|
||||
const MAX_FILES: usize = 20;
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
|
||||
/// PNG bytes for clipboard pixels, or why they can't be pasted.
|
||||
pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,14 @@ impl Previews {
|
|||
return None;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut img = font_entry_image(name, ppp);
|
||||
let mut img = snippet(
|
||||
&Document::new(),
|
||||
Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }),
|
||||
240.0,
|
||||
20.0,
|
||||
ppp,
|
||||
2.0,
|
||||
)?;
|
||||
ink_mask(&mut img);
|
||||
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
|
|
@ -250,20 +257,6 @@ pub fn ink_mask(img: &mut egui::ColorImage) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone
|
||||
/// and dropped (#848): browsing the list used to load every family shown for good, so each trip
|
||||
/// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a
|
||||
/// symbol font's), or a font that can't be read, shows in the default face.
|
||||
pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let face = db
|
||||
.preview_face(name)
|
||||
.filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c)))
|
||||
.unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular"));
|
||||
let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp);
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
|
||||
}
|
||||
|
||||
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
|
||||
pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option<egui::ColorImage> {
|
||||
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, false)
|
||||
|
|
@ -520,30 +513,6 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #848: each trip through the Font box loaded every family it showed, for good. Entries are
|
||||
/// now drawn with a face read for the preview alone: installed families stay unloaded.
|
||||
#[test]
|
||||
fn font_menu_entries_leave_installed_families_unloaded() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let ink = |img: &egui::ColorImage| img.pixels.iter().filter(|p| p.r() < 128).count();
|
||||
assert!(ink(&font_entry_image("Source Sans 3", 1.0)) > 20, "the bundled face draws");
|
||||
assert!(ink(&font_entry_image("No Such Font 848", 1.0)) > 20, "an unknown font shows in the default face");
|
||||
// Tests running alongside share this database and may load a fallback (a math font for
|
||||
// an equation, a font for a script) at any time: leave the math fonts out, and allow a
|
||||
// stray load or two. Before #848 every family previewed was loaded.
|
||||
let shared = |f: &str| wordcraft_fonts::math::MATH_SUBSTITUTES.iter().any(|n| f.eq_ignore_ascii_case(n));
|
||||
let unloaded: Vec<String> = db.families().into_iter().filter(|f| !db.is_loaded(f) && !shared(f)).take(25).collect();
|
||||
let mut loaded = Vec::new();
|
||||
for f in &unloaded {
|
||||
let img = font_entry_image(f, 1.0);
|
||||
assert_eq!(img.size, [244, 22]);
|
||||
if db.is_loaded(f) {
|
||||
loaded.push(f.clone());
|
||||
}
|
||||
}
|
||||
assert!(loaded.len() <= 2, "previewing loaded {loaded:?}");
|
||||
}
|
||||
|
||||
/// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark
|
||||
/// page; the light theme keeps a white page.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -738,8 +738,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
group(ui, "Editing", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| {
|
||||
// As in Word: Find searches in the Navigation pane; Advanced Find is the dialog (#712).
|
||||
mi(ui, app, "Find", "edit.find", json!({}));
|
||||
mi(ui, app, "Find", "ui.dialog", json!({"name": "find"}));
|
||||
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
|
||||
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Resizing tables with the mouse, like Word: over a column border (or the left edge) the pointer
|
||||
//! turns into a column-resize arrow, over a row's bottom border a row-resize arrow; over a table
|
||||
//! a small handle shows past its bottom-right corner. Dragging shows a dashed preview of the new border
|
||||
//! Resizing tables with the mouse, like Word: over a column border the pointer turns into a
|
||||
//! column-resize arrow, over a row's bottom border a row-resize arrow; over a table a small
|
||||
//! handle shows past its bottom-right corner. Dragging shows a dashed preview of the new border
|
||||
//! (or outline); releasing runs `table.resizeDrag` with page coordinates, one undo step. Shift
|
||||
//! while releasing a column border changes only the column on its left; a double-click on a
|
||||
//! column border fits the column to its contents (`table.fitColumn`). The geometry decisions
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
use egui::{Pos2, Rect, Stroke, Ui, pos2};
|
||||
use serde_json::json;
|
||||
use wordcraft_engine::cmd::table_resize::{self, Edge, Plan, ResizeTarget, Share};
|
||||
use wordcraft_engine::cmd::table_resize::{self, Edge, ResizeTarget};
|
||||
use wordcraft_layout::DocLayout;
|
||||
|
||||
use crate::WordApp;
|
||||
|
|
@ -24,54 +24,6 @@ pub(crate) struct ResizeDrag {
|
|||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
target: ResizeTarget,
|
||||
/// Started on a ruler marker, where Shift works the other way round, as on Word's ruler:
|
||||
/// without it a column border changes only the column on its left (the columns right of it
|
||||
/// move along), with it the neighbour makes up the difference.
|
||||
ruler: bool,
|
||||
}
|
||||
|
||||
impl ResizeDrag {
|
||||
/// Who makes up a column border's move, by the keys held now: Ctrl the columns on the
|
||||
/// right; Shift nobody on the page but the neighbour on a ruler marker, which works the other
|
||||
/// way round; else the neighbour on the page and nobody on the ruler.
|
||||
fn share(&self, ctx: &egui::Context) -> Share {
|
||||
let keys = ctx.input(|i| i.modifiers);
|
||||
if keys.command {
|
||||
Share::Proportional
|
||||
} else if keys.shift != self.ruler {
|
||||
Share::LeftOnly
|
||||
} else {
|
||||
Share::Neighbour
|
||||
}
|
||||
}
|
||||
|
||||
/// What the drag so far makes of the table, with the keys held now.
|
||||
fn plan(&self, ctx: &egui::Context) -> Plan {
|
||||
self.target.plan(self.to.0 - self.from.0, self.to.1 - self.from.1, self.share(ctx))
|
||||
}
|
||||
|
||||
/// Where the dragged column border (page x) or row bottom (page y) is now; `None` for the
|
||||
/// handle.
|
||||
fn line_now(&self, plan: &Plan) -> Option<f32> {
|
||||
match self.target.edge {
|
||||
Edge::Column(i) => Some(self.target.planned_x(plan, plan.cells.first().map(|(r, _)| *r), i)),
|
||||
Edge::Row(r) => planned_bottom(&self.target, plan, r),
|
||||
Edge::Corner => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page y of the bottom of `tg`'s row `row` with `plan` applied (rows above it that change move
|
||||
/// it too); `None` when the row isn't drawn on the page.
|
||||
fn planned_bottom(tg: &ResizeTarget, plan: &Plan, row: usize) -> Option<f32> {
|
||||
let bottom = tg.rows.iter().find(|r| r.row == row)?.bottom;
|
||||
let grown: f32 = tg
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.row <= row)
|
||||
.filter_map(|r| plan.heights.iter().find(|(i, _)| *i == r.row).map(|(_, h)| h - (r.bottom - r.top)))
|
||||
.sum();
|
||||
Some(bottom + grown)
|
||||
}
|
||||
|
||||
/// Screen pixels per page point on `page`.
|
||||
|
|
@ -107,10 +59,8 @@ fn target_at(app: &WordApp, layout: &DocLayout, rects: &[Rect], scale: f32, p: P
|
|||
Some((page, x, y, t))
|
||||
}
|
||||
|
||||
/// Mouse input: a press on a border starts a drag (in the selected cells' rows only, when it's a
|
||||
/// border of theirs), moving updates it, releasing applies it with the keys held then (Ctrl: the
|
||||
/// columns right of the border share the change). `false` (nothing done) when the pointer isn't
|
||||
/// on a border, so the caret logic runs.
|
||||
/// Mouse input: a press on a border starts a drag, moving updates it, releasing applies it.
|
||||
/// `false` (nothing done) when the pointer isn't on a border, so the caret logic runs.
|
||||
pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool {
|
||||
let latest = ui.input(|i| i.pointer.latest_pos());
|
||||
if let Some(mut d) = app.canvas.table_resize.take() {
|
||||
|
|
@ -139,11 +89,11 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
|
|||
}
|
||||
return true;
|
||||
}
|
||||
let share = d.share(ui.ctx());
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let sc = page_scale(rects, layout, scale, d.page).unwrap_or(1.0);
|
||||
let params = json!({
|
||||
"page": d.page, "x": d.from.0, "y": d.from.1, "toX": d.to.0, "toY": d.to.1,
|
||||
"shift": share == Share::LeftOnly, "ctrl": share == Share::Proportional, "reach": REACH_PX / sc,
|
||||
"shift": shift, "reach": REACH_PX / sc,
|
||||
});
|
||||
if let Err(e) = app.run("table.resizeDrag", params) {
|
||||
app.status(e);
|
||||
|
|
@ -154,55 +104,11 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
|
|||
return false;
|
||||
}
|
||||
let Some(p) = latest else { return false };
|
||||
let Some((page, x, y, mut target)) = target_at(app, layout, rects, scale, p) else { return false };
|
||||
target.limit_to_selection(&app.session, y);
|
||||
app.canvas.table_resize = Some(ResizeDrag { page, from: (x, y), to: (x, y), target, ruler: false });
|
||||
let Some((page, x, y, target)) = target_at(app, layout, rects, scale, p) else { return false };
|
||||
app.canvas.table_resize = Some(ResizeDrag { page, from: (x, y), to: (x, y), target });
|
||||
true
|
||||
}
|
||||
|
||||
/// Starts dragging the table border at page point (`x`, `y`) on `page` from a ruler marker: it
|
||||
/// goes on as a drag on the page (`pointer`: dashed preview, Escape cancels, one undo step), but
|
||||
/// a column border changes only the column on its left.
|
||||
pub(crate) fn grab(app: &mut WordApp, layout: &DocLayout, page: usize, x: f32, y: f32) {
|
||||
if !available(app) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut target) = table_resize::resize_target(layout, &app.session.doc, page, x, y, 1.0) {
|
||||
target.limit_to_selection(&app.session, y);
|
||||
app.canvas.table_resize = Some(ResizeDrag { page, from: (x, y), to: (x, y), target, ruler: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// The drag of one of the caret's table's borders on `page`, if one is going on.
|
||||
fn caret_drag(app: &WordApp, page: usize) -> Option<&ResizeDrag> {
|
||||
let caret = app.session.sel.focus.path.cell().map(|(tp, _, _)| tp);
|
||||
app.canvas
|
||||
.table_resize
|
||||
.as_ref()
|
||||
.filter(|d| d.page == page && d.target.story == app.session.sel.focus.story && caret.as_ref() == Some(&d.target.table))
|
||||
}
|
||||
|
||||
/// Whether the rulers show the caret's table's column widths: Alt is held while one of its
|
||||
/// column borders (or its left edge) is dragged on `page`, as in Word.
|
||||
pub(crate) fn measuring(app: &WordApp, ctx: &egui::Context, page: usize) -> bool {
|
||||
ctx.input(|i| i.modifiers.alt) && caret_drag(app, page).is_some_and(|d| matches!(d.target.edge, Edge::Column(_)))
|
||||
}
|
||||
|
||||
/// Where the caret's table's column border drawn at page x `at` (`column`; in the caret's row),
|
||||
/// or row bottom drawn at page y `at`, is while one of its borders is dragged on `page`: the
|
||||
/// rulers' markers follow the drag.
|
||||
pub(crate) fn moved(app: &WordApp, ctx: &egui::Context, page: usize, column: bool, at: f32) -> f32 {
|
||||
let Some(d) = caret_drag(app, page) else { return at };
|
||||
let (tg, plan) = (&d.target, d.plan(ctx));
|
||||
let near = |v: f32| (v - at).abs() < 0.5;
|
||||
if column {
|
||||
let row = app.session.sel.focus.path.cell().map(|(_, r, _)| r);
|
||||
tg.cols.iter().position(|x| near(*x)).map_or(at, |line| tg.planned_x(&plan, row, line))
|
||||
} else {
|
||||
tg.rows.iter().find(|r| near(r.bottom)).and_then(|r| planned_bottom(tg, &plan, r.row)).unwrap_or(at)
|
||||
}
|
||||
}
|
||||
|
||||
/// The resize arrow over a border or the handle, and while dragging.
|
||||
pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, rects: &[Rect], scale: f32) {
|
||||
let edge = match &app.canvas.table_resize {
|
||||
|
|
@ -227,8 +133,9 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay
|
|||
let s = |page: usize, x: f32, y: f32| to_screen(rects, layout, scale, page, x, y);
|
||||
let dash = Stroke::new(1.0, t.text);
|
||||
if let Some(d) = &app.canvas.table_resize {
|
||||
let shift = painter.ctx().input(|i| i.modifiers.shift);
|
||||
let tg = &d.target;
|
||||
let plan = d.plan(painter.ctx());
|
||||
let plan = tg.plan(d.to.0 - d.from.0, d.to.1 - d.from.1, shift);
|
||||
let (left, top) = (tg.left(), tg.top());
|
||||
let right = plan.widths.as_ref().map_or(tg.right(), |w| left + w.iter().sum::<f32>());
|
||||
let bottom = top + tg.rows.iter().map(|r| plan.heights.iter().find(|(i, _)| *i == r.row).map_or(r.bottom - r.top, |(_, h)| *h)).sum::<f32>();
|
||||
|
|
@ -237,19 +144,15 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay
|
|||
painter.extend(egui::Shape::dashed_line(&[a, b], dash, 3.0, 3.0));
|
||||
}
|
||||
};
|
||||
// The dragged column border's x, or row bottom's y.
|
||||
let at = d.line_now(&plan).unwrap_or_default();
|
||||
match tg.edge {
|
||||
Edge::Column(_) => {
|
||||
// Down the rows it moves in (the selected cells'), else the whole table.
|
||||
let rows: Vec<_> = tg.rows.iter().filter(|r| plan.cells.is_empty() || plan.cells.iter().any(|(c, _)| *c == r.row)).collect();
|
||||
let (y0, y1) =
|
||||
(rows.iter().map(|r| r.top).fold(f32::INFINITY, f32::min), rows.iter().map(|r| r.bottom).fold(f32::NEG_INFINITY, f32::max));
|
||||
if y0 <= y1 {
|
||||
line(s(d.page, at, y0), s(d.page, at, y1));
|
||||
}
|
||||
Edge::Column(i) => {
|
||||
let x = left + plan.widths.as_ref().map_or(0.0, |w| w.iter().take(i).sum::<f32>());
|
||||
line(s(d.page, x, top), s(d.page, x, tg.bottom()));
|
||||
}
|
||||
Edge::Row(r) => {
|
||||
let y = tg.rows.iter().find(|x| x.row == r).map_or(top, |x| x.top) + plan.heights.first().map_or(0.0, |(_, h)| *h);
|
||||
line(s(d.page, left, y), s(d.page, tg.right(), y));
|
||||
}
|
||||
Edge::Row(_) => line(s(d.page, left, at), s(d.page, tg.right(), at)),
|
||||
Edge::Corner => {
|
||||
if let (Some(a), Some(b)) = (s(d.page, left, top), s(d.page, right, bottom)) {
|
||||
let r = Rect::from_two_pos(a, b);
|
||||
|
|
@ -380,191 +283,6 @@ pub(crate) mod tests {
|
|||
assert_eq!(cell(&mut h, 0, 0), a);
|
||||
}
|
||||
|
||||
/// The zone of the ruler marker named `label` nearest to screen point `near`.
|
||||
fn ruler_marker(h: &H, label: &str, near: Pos2) -> Rect {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.query_all_by_label(label).map(|n| n.rect()).min_by(|p, q| p.center().distance(near).total_cmp(&q.center().distance(near))).expect(label)
|
||||
}
|
||||
|
||||
/// A real mouse drag from `from` to `to` (screen points), in a few moves.
|
||||
fn drag(h: &mut H, from: Pos2, to: Pos2) {
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.step();
|
||||
button(h, from, true);
|
||||
h.step();
|
||||
for k in 1..=5 {
|
||||
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 5.0)));
|
||||
h.step();
|
||||
}
|
||||
button(h, to, false);
|
||||
steps(h, 3);
|
||||
}
|
||||
|
||||
/// #49: with the caret in the table, a column marker on the horizontal ruler drags its border,
|
||||
/// in one undo step; only the column left of it changes and the columns right of it move
|
||||
/// along, as on Word's ruler. The marker follows the drag.
|
||||
#[test]
|
||||
fn a_column_marker_on_the_ruler_moves_the_columns_right_of_it() {
|
||||
let mut h = table_window();
|
||||
let (a, b, c) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
let depth = h.state().session.undo_depth();
|
||||
let near = screen(&mut h, a.right(), a.y);
|
||||
let start = ruler_marker(&h, "Table Column Width", near).center();
|
||||
assert!((start.x - near.x).abs() < 0.5, "a marker over the first column border");
|
||||
let end = start + egui::vec2(screen(&mut h, a.right() + 30.0, a.y).x - near.x, 0.0);
|
||||
h.event(egui::Event::PointerMoved(start));
|
||||
h.step();
|
||||
button(&h, start, true);
|
||||
h.step();
|
||||
for k in 1..=5 {
|
||||
h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0)));
|
||||
h.step();
|
||||
}
|
||||
assert!((ruler_marker(&h, "Table Column Width", end).center().x - end.x).abs() < 1.0, "the marker follows the pointer");
|
||||
assert_eq!(h.state().session.undo_depth(), depth, "nothing is written while dragging");
|
||||
button(&h, end, false);
|
||||
steps(&mut h, 3);
|
||||
assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the drag");
|
||||
let (a1, b1, c1) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
assert!((b1.w - b.w).abs() < 0.5 && (c1.w - c.w).abs() < 0.5, "the columns right of it keep their widths");
|
||||
assert!((c1.right() - (c.right() + 30.0)).abs() < 1.0, "and move along, so the table is wider");
|
||||
}
|
||||
|
||||
/// With Shift a column marker on the ruler works the other way round, as on Word's ruler: the
|
||||
/// neighbour on the right makes up the difference, so the table keeps its width.
|
||||
#[test]
|
||||
fn a_shift_drag_on_the_ruler_trades_width_with_the_neighbour() {
|
||||
let mut h = table_window();
|
||||
let (a, b, c) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
let near = screen(&mut h, a.right(), a.y);
|
||||
let start = ruler_marker(&h, "Table Column Width", near).center();
|
||||
let end = start + egui::vec2(screen(&mut h, a.right() + 30.0, a.y).x - near.x, 0.0);
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
|
||||
drag(&mut h, start, end);
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
let (a1, b1, c1) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
assert!((b1.w - (b.w - 30.0)).abs() < 1.0, "{b:?} → {b1:?}");
|
||||
assert!((c1.w - c.w).abs() < 0.5 && (c1.right() - c.right()).abs() < 1.0, "the table keeps its width");
|
||||
}
|
||||
|
||||
/// #49: a row marker on the vertical ruler drags that row's bottom border: the row gets taller,
|
||||
/// in one undo step.
|
||||
#[test]
|
||||
fn a_row_marker_on_the_ruler_sets_the_row_height() {
|
||||
let mut h = table_window();
|
||||
let a = cell(&mut h, 0, 0);
|
||||
let depth = h.state().session.undo_depth();
|
||||
let near = screen(&mut h, a.x, a.bottom());
|
||||
let start = ruler_marker(&h, "Table Row Height", near).center();
|
||||
assert!((start.y - near.y).abs() < 0.5, "a marker at the first row's bottom");
|
||||
let end = start + egui::vec2(0.0, screen(&mut h, a.x, a.bottom() + 20.0).y - near.y);
|
||||
drag(&mut h, start, end);
|
||||
assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the drag");
|
||||
let a1 = cell(&mut h, 0, 0);
|
||||
assert!((a1.h - (a.h + 20.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
}
|
||||
|
||||
/// #49: in a cell the ruler's indents are the cell's: the left-indent marker sits where the
|
||||
/// cell's text starts (its box less its margins), and dragging it 18 points indents the
|
||||
/// paragraph 18 points.
|
||||
#[test]
|
||||
fn in_a_cell_the_ruler_measures_indents_from_its_text() {
|
||||
let mut h = table_window();
|
||||
let b = cell(&mut h, 0, 1);
|
||||
// Into the second cell: its text starts far from the page's left margin.
|
||||
let inside = screen(&mut h, b.x + b.w / 2.0, b.y + b.h / 2.0);
|
||||
h.event(egui::Event::PointerMoved(inside));
|
||||
h.step();
|
||||
button(&h, inside, true);
|
||||
h.step();
|
||||
button(&h, inside, false);
|
||||
steps(&mut h, 3);
|
||||
assert_eq!(h.state().session.sel.focus.path.cell().map(|(_, r, cl)| (r, cl)), Some((0, 1)));
|
||||
let layout = h.state_mut().session.layout();
|
||||
let (left, w) = crate::table_ruler::text_area(h.state(), &layout, 0).unwrap();
|
||||
assert!((left - (b.x + 5.4)).abs() < 0.01 && (w - (b.w - 10.8)).abs() < 0.01, "the cell less its margins: {left}, {w}");
|
||||
// The left-indent marker is at the text's start, just above the ruler's bottom edge.
|
||||
let near = screen(&mut h, left, b.y);
|
||||
let bar_bottom = ruler_marker(&h, "Table Column Width", near).bottom();
|
||||
let start = egui::pos2(near.x, bar_bottom - 3.0);
|
||||
let end = start + egui::vec2(screen(&mut h, left + 18.0, b.y).x - near.x, 0.0);
|
||||
drag(&mut h, start, end);
|
||||
let focus = h.state().session.sel.focus.clone();
|
||||
assert_eq!(h.state().session.doc.para_at(&focus).and_then(|p| p.props.indent_left), Some(18.0));
|
||||
}
|
||||
|
||||
/// With Ctrl held, the change of a dragged column border is shared by the columns right of
|
||||
/// it, so the table keeps its width.
|
||||
#[test]
|
||||
fn a_ctrl_drag_shares_the_change_among_the_columns_right_of_it() {
|
||||
let mut h = table_window();
|
||||
let (a, b, c) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
let y = a.y + a.h / 2.0;
|
||||
let (start, end) = (screen(&mut h, a.right(), y), screen(&mut h, a.right() + 30.0, y));
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
|
||||
drag(&mut h, start, end);
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
let (a1, b1, c1) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1), cell(&mut h, 0, 2));
|
||||
assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
assert!((b1.w - (b.w - 15.0)).abs() < 1.0 && (c1.w - (c.w - 15.0)).abs() < 1.0, "{b1:?} {c1:?}");
|
||||
assert!((c1.right() - c.right()).abs() < 1.0, "the table keeps its width");
|
||||
}
|
||||
|
||||
/// The table's left edge, under the resize arrow just outside it, drags the table in; the
|
||||
/// first column makes up the difference. The horizontal ruler has a marker on it too.
|
||||
#[test]
|
||||
fn the_left_edge_drags_the_table_in_and_has_a_ruler_marker() {
|
||||
let mut h = table_window();
|
||||
let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1));
|
||||
let y = a.y + a.h / 2.0;
|
||||
let (start, end) = (screen(&mut h, a.x - 1.5, y), screen(&mut h, a.x + 20.0, y));
|
||||
h.event(egui::Event::PointerMoved(start));
|
||||
steps(&mut h, 2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn, "the resize arrow on the left edge");
|
||||
drag(&mut h, start, end);
|
||||
let (a1, b1) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1));
|
||||
assert!((a1.x - (a.x + 21.5)).abs() < 1.0 && (a1.right() - a.right()).abs() < 0.5, "{a:?} → {a1:?}");
|
||||
assert!((b1.x - b.x).abs() < 0.5 && (b1.w - b.w).abs() < 0.5, "{b:?} → {b1:?}");
|
||||
let near = screen(&mut h, a1.x, a1.y);
|
||||
let marker = ruler_marker(&h, "Table Column Width", near).center();
|
||||
assert!((marker.x - near.x).abs() < 0.5, "a marker on the left edge");
|
||||
let back = marker - egui::vec2(near.x - screen(&mut h, a.x, a1.y).x, 0.0);
|
||||
drag(&mut h, marker, back);
|
||||
assert!((cell(&mut h, 0, 0).x - a.x).abs() < 1.0, "the marker drags it back out");
|
||||
}
|
||||
|
||||
/// Alt while a column border is dragged shows each column's width on the ruler, as it is
|
||||
/// going to be.
|
||||
#[test]
|
||||
fn alt_shows_the_column_widths_while_dragging() {
|
||||
let mut h = table_window();
|
||||
let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1));
|
||||
let y = a.y + a.h / 2.0;
|
||||
let (start, end) = (screen(&mut h, a.right(), y), screen(&mut h, a.right() + 18.0, y));
|
||||
h.event(egui::Event::PointerMoved(start));
|
||||
h.step();
|
||||
button(&h, start, true);
|
||||
h.step();
|
||||
h.event(egui::Event::PointerMoved(end));
|
||||
h.step();
|
||||
let widths = |h: &mut H| {
|
||||
let layout = h.state_mut().session.layout();
|
||||
crate::table_ruler::widths(h.state(), &h.ctx, &layout, 0)
|
||||
};
|
||||
assert!(widths(&mut h).is_empty(), "not without Alt");
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
|
||||
h.step();
|
||||
let w = widths(&mut h);
|
||||
assert_eq!(w.len(), 3, "{w:?}");
|
||||
assert!((w[0].1 - (a.w + 18.0)).abs() < 0.5 && (w[1].1 - (b.w - 18.0)).abs() < 0.5, "{w:?}");
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
button(&h, end, false);
|
||||
steps(&mut h, 3);
|
||||
assert!(widths(&mut h).is_empty(), "gone with the drag");
|
||||
}
|
||||
|
||||
/// Clicks inside a cell still place the caret (the border doesn't steal them), and Esc drops a
|
||||
/// drag without changing anything.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
//! A table on the rulers, like Word's: with the caret in a table, the horizontal ruler marks the
|
||||
//! table's left edge and the caret row's column borders, and the vertical ruler the bottom of
|
||||
//! each of the table's rows on the page. Dragging a marker drags that border as on the page
|
||||
//! (`table_resize::grab`), except that on the horizontal ruler only the column left of the border
|
||||
//! changes and the columns right of it move along. Alt while dragging a column border shows the
|
||||
//! row's column widths (`widths`). A paragraph's indents and tabs in a cell are measured from the
|
||||
//! cell's text area (`text_area`).
|
||||
|
||||
use egui::{Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The cells of the caret's table drawn on `page`: (row, cell, box in page points).
|
||||
fn cells(app: &WordApp, layout: &DocLayout, page: usize) -> Vec<(usize, usize, wordcraft_geom::Rect)> {
|
||||
let story = app.session.sel.focus.story;
|
||||
let (Some((tp, _, _)), Some(pg)) = (app.session.sel.focus.path.cell(), layout.pages.get(page)) else { return Vec::new() };
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *st == story => Some((*row, *cell, *rect)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The caret cell's text area on `page`, as (left edge, width) in page points: its box less its
|
||||
/// left and right margins. `None` when the caret isn't in a table cell drawn there.
|
||||
pub(crate) fn text_area(app: &WordApp, layout: &DocLayout, page: usize) -> Option<(f32, f32)> {
|
||||
let story = app.session.sel.focus.story;
|
||||
let (tp, r, c) = app.session.sel.focus.path.cell()?;
|
||||
let (.., b) = cells(app, layout, page).into_iter().find(|(row, cell, _)| *row == r && *cell == c)?;
|
||||
let m = wordcraft_layout::cell_margins(&app.session.doc, app.session.doc.table(story, &tp)?, r, c);
|
||||
let (left, right) = (m[1].max(0.0), m[3].max(0.0));
|
||||
Some((b.x + left, (b.w - left - right).max(0.0)))
|
||||
}
|
||||
|
||||
/// A marker on a ruler: a small grip across the bar at `r`.
|
||||
fn marker(painter: &egui::Painter, r: Rect, t: &Tokens) {
|
||||
painter.rect_filled(r, 1.0, t.ruler_margin);
|
||||
painter.rect_stroke(r, 1.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
|
||||
let c = r.center();
|
||||
let (along, across) = if r.height() > r.width() { (vec2(0.0, 3.0), vec2(1.5, 0.0)) } else { (vec2(3.0, 0.0), vec2(0.0, 1.5)) };
|
||||
for k in [-1.0, 0.0, 1.0] {
|
||||
let m = c + along * k;
|
||||
painter.line_segment([m - across, m + across], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker `i`'s drag zone on the column (or row) ruler, named for screen readers and with its
|
||||
/// tooltip: a press starts dragging the border at `at` (page, and page point on the border).
|
||||
fn grip(app: &mut WordApp, ui: &Ui, layout: &DocLayout, zone: Rect, column: bool, i: usize, at: (usize, f32, f32)) {
|
||||
let (name, label, cursor) = if column {
|
||||
("ruler_column", tl!("Table Column Width"), egui::CursorIcon::ResizeColumn)
|
||||
} else {
|
||||
("ruler_row", tl!("Table Row Height"), egui::CursorIcon::ResizeRow)
|
||||
};
|
||||
let r = ui.interact(zone, ui.id().with((name, i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, label));
|
||||
let r = if r.dragged() { r } else { r.on_hover_text(label) };
|
||||
if r.drag_started() {
|
||||
crate::table_resize::grab(app, layout, at.0, at.1, at.2);
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret row's cells on `page`, left to right.
|
||||
fn caret_row(app: &WordApp, layout: &DocLayout, page: usize) -> Vec<wordcraft_geom::Rect> {
|
||||
let Some((_, r, _)) = app.session.sel.focus.path.cell() else { return Vec::new() };
|
||||
let mut row: Vec<wordcraft_geom::Rect> = cells(app, layout, page).into_iter().filter(|(row, ..)| *row == r).map(|(.., b)| b).collect();
|
||||
row.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
row
|
||||
}
|
||||
|
||||
/// While Alt is held during a column border's drag (`table_resize::measuring`), each of the
|
||||
/// caret row's cells on `page` as it is going to be: (middle, width), page points.
|
||||
pub(crate) fn widths(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, page: usize) -> Vec<(f32, f32)> {
|
||||
if !crate::table_resize::measuring(app, ctx, page) {
|
||||
return Vec::new();
|
||||
}
|
||||
let at = |x: f32| crate::table_resize::moved(app, ctx, page, true, x);
|
||||
caret_row(app, layout, page)
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let (l, r) = (at(b.x), at(b.right()));
|
||||
((l + r) / 2.0, r - l)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The horizontal ruler's markers (`bar`; the page's left edge at screen x `x0`, `scale` pixels per
|
||||
/// point): one on the table's left edge and on each column border of the caret's row, and with
|
||||
/// Alt held while one is dragged, the columns' widths between them.
|
||||
pub(crate) fn columns(app: &mut WordApp, ui: &Ui, layout: &DocLayout, page: usize, bar: Rect, x0: f32, scale: f32) {
|
||||
let row = caret_row(app, layout, page);
|
||||
let Some(first) = row.first().copied() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter_at(bar.expand(1.0));
|
||||
// The table's left edge, then each cell's right border: where it is, or while a border of
|
||||
// the table is dragged, where it's going.
|
||||
let edges: Vec<(f32, f32)> = std::iter::once(first.x)
|
||||
.chain(row.iter().map(|b| b.right()))
|
||||
.map(|x| (x, x0 + crate::table_resize::moved(app, ui.ctx(), page, true, x) * scale))
|
||||
.collect();
|
||||
for (i, (x, sx)) in edges.into_iter().enumerate() {
|
||||
marker(&painter, Rect::from_center_size(pos2(sx, bar.center().y), vec2(7.0, bar.height() - 2.0)), &t);
|
||||
let zone = Rect::from_center_size(pos2(sx, bar.center().y), vec2(8.0, bar.height()));
|
||||
grip(app, ui, layout, zone, true, i, (page, x, first.y + first.h / 2.0));
|
||||
}
|
||||
for (mid, w) in widths(app, ui.ctx(), layout, page) {
|
||||
let at = pos2(x0 + mid * scale, bar.center().y);
|
||||
let text = painter.layout_no_wrap(wordcraft_geom::Unit::default().format(w), crate::theme::regular(9.5), t.text);
|
||||
painter.rect_filled(Rect::from_center_size(at, text.size() + vec2(4.0, 0.0)), 1.0, t.ruler);
|
||||
painter.galley(at - text.size() / 2.0, text, t.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical ruler's markers (`bar`; the page's top at screen y `y0`, `scale` pixels per
|
||||
/// point): one on the bottom of each of the caret's table's rows on the page.
|
||||
pub(crate) fn rows(app: &mut WordApp, ui: &Ui, layout: &DocLayout, page: usize, bar: Rect, y0: f32, scale: f32) {
|
||||
// Each row's bottom, and the cell it's taken from (a press in the middle of it grabs the
|
||||
// bottom): its shortest box, as a vertically merged cell's box spans all its rows (#640), and
|
||||
// of those the leftmost.
|
||||
let mut rows: Vec<(usize, wordcraft_geom::Rect)> = Vec::new();
|
||||
for (r, _, b) in cells(app, layout, page) {
|
||||
match rows.iter_mut().find(|(row, _)| *row == r) {
|
||||
Some((_, first)) if b.bottom() < first.bottom() - 0.01 || (b.bottom() <= first.bottom() + 0.01 && b.x < first.x) => *first = b,
|
||||
Some(_) => {}
|
||||
None => rows.push((r, b)),
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter_at(bar.expand(1.0));
|
||||
for (r, b) in rows {
|
||||
let sy = y0 + crate::table_resize::moved(app, ui.ctx(), page, false, b.bottom()) * scale;
|
||||
marker(&painter, Rect::from_center_size(pos2(bar.center().x, sy), vec2(bar.width() - 2.0, 7.0)), &t);
|
||||
let zone = Rect::from_center_size(pos2(bar.center().x, sy), vec2(bar.width(), 8.0));
|
||||
grip(app, ui, layout, zone, false, r, (page, b.x + b.w / 2.0, b.bottom()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
//! Scrolling performance with a very large table (#811).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Document, Path, StoryRef};
|
||||
|
||||
use crate::{Services, WordApp};
|
||||
|
||||
/// A document with one `rows × cols` table, every cell holding a few words.
|
||||
pub(crate) fn big_table_doc(rows: usize, cols: usize) -> Document {
|
||||
let mut d = Document::from_text("Before the table\nAfter the table");
|
||||
let mut t = Table::new(rows, cols, 468.0);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
let w = cell.props.width;
|
||||
*cell = Cell::with_text(&format!("R{r} C{c} value {}", r * cols + c));
|
||||
cell.props.width = w;
|
||||
}
|
||||
}
|
||||
let _ = d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t));
|
||||
d
|
||||
}
|
||||
|
||||
fn harness(doc: Document) -> egui_kittest::Harness<'static, WordApp> {
|
||||
let a = WordApp::new(wordcraft_engine::Session::new(doc), Services::default());
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
)
|
||||
}
|
||||
|
||||
fn wheel(h: &mut egui_kittest::Harness<'static, WordApp>, dy: f32) {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(720.0, 500.0)));
|
||||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, dy),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
}
|
||||
|
||||
/// `cargo test -p wordcraft-ui-egui --release big_table_scroll_bench -- --ignored --nocapture`
|
||||
/// (`WORDCRAFT_BENCH_DOCX=path` times that file instead of the synthetic table).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn big_table_scroll_bench() {
|
||||
let doc = match std::env::var("WORDCRAFT_BENCH_DOCX") {
|
||||
Ok(p) => wordcraft_engine::io::open_path(std::path::Path::new(&p)).unwrap(),
|
||||
Err(_) => big_table_doc(2000, 8),
|
||||
};
|
||||
let mut s = wordcraft_engine::Session::new(doc.clone());
|
||||
let t = Instant::now();
|
||||
let l = s.layout();
|
||||
eprintln!("layout: {:.1} ms, {} pages", t.elapsed().as_secs_f64() * 1e3, l.pages.len());
|
||||
let t = Instant::now();
|
||||
let _ = s.export_layout();
|
||||
eprintln!("export_layout: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
if let Some(p) = l.pages.get(1) {
|
||||
let t = Instant::now();
|
||||
let _ = wordcraft_render::render_page(&s.doc, p, 1.5, &crate::canvas::screen_render_options());
|
||||
eprintln!("render_page: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let mut h = harness(doc);
|
||||
let t = Instant::now();
|
||||
h.run_steps(3);
|
||||
eprintln!("first frames: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
let mut idle = Vec::new();
|
||||
for _ in 0..10 {
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
idle.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
for _ in 0..40 {
|
||||
wheel(&mut h, -120.0);
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
steps.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let stat = |v: &mut Vec<f64>| {
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
(v.get(v.len() / 2).copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0))
|
||||
};
|
||||
eprintln!("idle frame: median/max {:.2?}", stat(&mut idle));
|
||||
eprintln!("scroll frame: median/max {:.2?}", stat(&mut steps));
|
||||
eprintln!("scroll offset after: {:?}", h.state().canvas.scroll_offset);
|
||||
}
|
||||
|
||||
/// #811: scrolling through one very large table was slow because every frame fingerprinted every
|
||||
/// item of every page (thousands of cell borders a page) for the page texture cache, and every
|
||||
/// page ever shown kept its texture. Now a frame lays nothing out again, fingerprints a page once
|
||||
/// per layout and only when it is on screen, and keeps textures only near the view.
|
||||
#[test]
|
||||
fn scrolling_a_big_table_fingerprints_only_pages_coming_into_view() {
|
||||
let mut h = harness(big_table_doc(600, 8));
|
||||
h.run_steps(4);
|
||||
let layout = h.state_mut().session.layout();
|
||||
let pages = layout.pages.len();
|
||||
assert!(pages > 30, "the table runs over many pages ({pages})");
|
||||
let first = h.state().canvas.page_keys.hashed;
|
||||
assert!(first > 0 && first < 6, "only the pages on screen are fingerprinted, not all {pages}: {first}");
|
||||
// Idle frames do no per-page work.
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().canvas.page_keys.hashed, first, "idle frames fingerprint nothing");
|
||||
// Scroll a long way down: each newly shown page is fingerprinted once.
|
||||
let mut max_textures = 0;
|
||||
for _ in 0..60 {
|
||||
wheel(&mut h, -400.0);
|
||||
h.step();
|
||||
max_textures = max_textures.max(h.state().canvas.texture_count());
|
||||
}
|
||||
let offset = h.state().canvas.scroll_offset.y;
|
||||
assert!(offset > 20_000.0, "scrolled through many pages: {offset}");
|
||||
let after = std::sync::Arc::clone(&h.state_mut().session.layout());
|
||||
assert!(std::sync::Arc::ptr_eq(&layout, &after), "scrolling never lays the document out again");
|
||||
let hashed = h.state().canvas.page_keys.hashed;
|
||||
// 60 steps of 400 pt cross about 24'000 / 1'056 ≈ 23 pages (at 100%, 1.33 px a point).
|
||||
assert!(hashed < pages && hashed <= first + 30, "pages fingerprinted: {hashed} of {pages}");
|
||||
// Textures stay bounded to the pages near the view.
|
||||
assert!(max_textures <= 12, "textures kept while scrolling: {max_textures}");
|
||||
assert!(h.state().canvas.texture_count() >= 1, "the pages in view stay rendered");
|
||||
}
|
||||
|
|
@ -68,9 +68,6 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
|||
|
||||
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
|
||||
|
||||
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
|
||||
in [MCP server](mcp.md#character-and-paragraph-properties).
|
||||
|
||||
Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`.
|
||||
|
||||
### Native printing
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
|
|
@ -25,9 +25,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
|||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687) |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
|
|
@ -37,7 +37,6 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
|||
| `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources |
|
||||
| `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word |
|
||||
| `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math |
|
||||
| Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC |
|
||||
| `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | |
|
||||
| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents |
|
||||
|
||||
|
|
@ -165,9 +164,6 @@ raise readiness estimates. No Word-derived files are fixtures.
|
|||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
|
||||
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
|
||||
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
|
||||
| 2026-10-12 | trivial | `w:sym` and symbol-font runs keep their stored characters on save; TXT export and copy write their Unicode equivalents (#776) |
|
||||
| 2026-10-12 | trivial | Insert › Picture, drag-and-drop and paste take Windows Metafiles and Enhanced Metafiles (#802); new row for picture formats |
|
||||
| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added |
|
||||
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
|
||||
| 2026-10-12 | trivial | DOCX: a table of contents' entry links (Hyperlink-styled runs in hyperlinks inside a TOC field) import without the Hyperlink style, as Word draws them; measured in Word for Windows |
|
||||
|
|
|
|||
15
docs/gaps.md
15
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
|
|
@ -16,8 +16,10 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
|
|
@ -34,7 +36,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
|||
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-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; 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, sections, RTL | | ~1,500 lines each in `crates/formats` | 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) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
|
|
@ -60,7 +62,9 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
their formatting (system clipboard HTML/RTF), Merge Formatting keeps emphasis, Paste Special lists
|
||||
what the system clipboard holds, and Linux/Wayland clipboard pictures paste (#687, #833);
|
||||
still missing: the Paste Options button after a paste, and RTF on macOS and X11.
|
||||
- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File ›
|
||||
- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column markers on the
|
||||
ruler (#49; dragging borders and the resize handle on the page landed, #217, and selecting, inserting and moving with the mouse);
|
||||
context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File ›
|
||||
Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD
|
||||
desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real
|
||||
GNOME or KDE session yet.
|
||||
|
|
@ -73,13 +77,11 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps |
|
||||
| 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain |
|
||||
| 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps |
|
||||
| 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps |
|
||||
| 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut |
|
||||
| 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL |
|
||||
| 2026-10-12 | trivial | Gap #3: browsing the Font list no longer keeps every previewed family in memory (#848); each applied family still loads once |
|
||||
| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) |
|
||||
| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only |
|
||||
| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open |
|
||||
|
|
@ -143,4 +145,3 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
|||
| 2026-10-11 | trivial | #491: vertical sections landed; vertical text removed from the East Asian gap |
|
||||
| 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours |
|
||||
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed |
|
||||
| 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
|
|
@ -24,7 +24,6 @@ font can't match Word's metrics.
|
|||
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
|
||||
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
|
||||
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
|
||||
| Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — |
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 |
|
||||
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
|
||||
|
|
@ -34,7 +33,7 @@ font can't match Word's metrics.
|
|||
| Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization |
|
||||
| Drop caps | ✅ | 2026-10-06 | — |
|
||||
| Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — |
|
||||
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 |
|
||||
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42) | 1–2 |
|
||||
| List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal | — |
|
||||
| Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — |
|
||||
| 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); 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); per-section restart partial | 1–3 |
|
||||
|
|
@ -73,7 +72,6 @@ font can't match Word's metrics.
|
|||
| Relayout after an edit | 1.4 ms | not measured | measured 2026-10-06 |
|
||||
| Font picker with many installed fonts | fixed freezes (#233) | instant | issue #31, #59 |
|
||||
| Large real-world documents (500+ pages, many pictures) | not measured | | gap |
|
||||
| Scrolling a 1,096-page document that is one 5,300-row table (#811) | ~2.4 ms idle, ~4.5 ms median scroll frame (was ~600 ms); opening still lays out for ~4–8 s | not measured | measured 2026-10-12, release, headless (`tests_perf::big_table_scroll_bench`) |
|
||||
|
||||
## Revision history
|
||||
|
||||
|
|
@ -91,9 +89,6 @@ font can't match Word's metrics.
|
|||
| 2026-10-12 | trivial | Space before at the top of a page after page breaks, measured in Word for Windows in compatibility modes 12 and 15 (32 cases) |
|
||||
| 2026-10-12 | trivial | No automatic (HTML-style) spacing between two list items, of one list or two, at any level; the first item keeps its automatic space before and the last its space after |
|
||||
| 2026-10-12 | trivial | Kerning only where the document asks for it, and automatic (HTML-style) paragraph spacing, both measured in Word for Windows against real .gov documents |
|
||||
| 2026-10-12 | trivial | A section break inserted over a selection keeps the selected text: the first section no longer comes out empty (#852) |
|
||||
| 2026-10-12 | trivial | Large-table scrolling (#811): per-frame page fingerprinting fixed; measurement added |
|
||||
| 2026-10-12 | trivial | Symbol-font text (Wingdings, Wingdings 2/3, Webdings, Symbol) drawn as Unicode symbols (#776) |
|
||||
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
|
||||
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
|
||||
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ native-speaker review (human).
|
|||
## What WordCraft ships (measured)
|
||||
|
||||
- **19 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`,
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,057–2,454 entries
|
||||
each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
|
||||
Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,021–2,419 entries
|
||||
each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian, Hungarian, Turkish and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command
|
||||
label and ribbon location is translated in every language.
|
||||
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
|
||||
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
|
||||
|
|
|
|||
79
docs/mcp.md
79
docs/mcp.md
|
|
@ -97,82 +97,3 @@ files it was given rather than guarding against other software on the computer.
|
|||
Resources: `wordcraft://document` (inspect) and `wordcraft://commands`.
|
||||
|
||||
The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only.
|
||||
|
||||
## Character and paragraph properties
|
||||
|
||||
`format.set {"props": CharProps}`, `para.set {"props": ParaProps}`,
|
||||
`styles.modify {"style": …, "chr"?: CharProps, "para"?: ParaProps}` and the `para` of
|
||||
`table.newStyle` / `table.modifyStyle` take these two objects. Every field is optional: a field
|
||||
left out keeps what the style chain gives. A field name the object doesn't know is ignored, not
|
||||
refused, so check the spelling. Lengths are points; `[r, g, b]` is a colour as three numbers
|
||||
0–255.
|
||||
|
||||
```json
|
||||
{"command": "styles.modify", "params": {"style": "Heading1",
|
||||
"chr": {"font": "Georgia", "size": 18, "bold": true, "color": {"Rgb": [27, 22, 17]}},
|
||||
"para": {"spaceBefore": 12, "lineSpacing": {"rule": "multiple", "value": 1.15}, "keepNext": true}}}
|
||||
```
|
||||
|
||||
### CharProps
|
||||
|
||||
| Field | Type | Values |
|
||||
|---|---|---|
|
||||
| `style` | string | character style id |
|
||||
| `font` | string | font name |
|
||||
| `size` | number | points |
|
||||
| `bold`, `italic`, `strike`, `doubleStrike`, `caps`, `smallCaps`, `hidden`, `outline`, `shadow`, `emboss`, `engrave`, `noProof` | bool | |
|
||||
| `underline` | string | `none`, `single`, `words`, `double`, `thick`, `dotted`, `dash`, `dotDash`, `dotDotDash`, `wave`, `doubleWave` |
|
||||
| `underlineColor` | `[r, g, b]` | |
|
||||
| `color` | `"Auto"`, a hex string `"RRGGBB"` or `"#RRGGBB"`, or `{"Rgb": [r, g, b]}` | `Auto` (also `"auto"`) is black on light backgrounds, white on dark; read back as `"Auto"` or `{"Rgb": …}` (#865) |
|
||||
| `highlight` | string | `none`, `yellow`, `brightGreen`, `turquoise`, `pink`, `blue`, `red`, `darkBlue`, `teal`, `green`, `violet`, `darkRed`, `darkYellow`, `gray50`, `gray25`, `black`, `white` |
|
||||
| `shading` | `[r, g, b]` | background fill |
|
||||
| `border` | Border | character border; `"style": "none"` is explicitly no border |
|
||||
| `vertAlign` | string | `baseline`, `superscript`, `subscript` |
|
||||
| `spacing` | number | character spacing, points: positive expands, negative condenses |
|
||||
| `scale` | number | horizontal scale, percent (100 = normal) |
|
||||
| `position` | number | raised (positive) or lowered (negative), points |
|
||||
| `kern` | number | kern fonts at or above this size, points; 0 = off |
|
||||
| `textEffects` | object | WordArt `fill`, `outline`, `shadow`, `glow`, `reflection` (see `crates/doc/src/wordart.rs`) |
|
||||
| `lang` | string | BCP 47 language tag (proofing) |
|
||||
| `rtl` | bool | right-to-left run |
|
||||
| `cs` | bool | format the whole run with the complex-script properties |
|
||||
| `fontCs`, `fontEa` | string | complex-script font; East Asian font |
|
||||
| `sizeCs` | number | complex-script size, points |
|
||||
| `boldCs`, `italicCs` | bool | complex-script bold, italic |
|
||||
| `langBidi` | string | BCP 47 language of complex-script text, e.g. `fa-IR` |
|
||||
| `link` | string | hyperlink target: a URL, or `#bookmark` |
|
||||
| `ins`, `del`, `fmtChange` | | Track Changes bookkeeping (revision indexes) |
|
||||
|
||||
`format.set` also applies `size`, `bold` and `italic` to `sizeCs`, `boldCs` and `italicCs` when
|
||||
those aren't given; `styles.modify` sets only what it is given.
|
||||
|
||||
A **Border** is `{"style": …, "width": pt, "color": [r, g, b] | null, "space": pt}` (`space` is
|
||||
the distance from the text). Missing fields are `none`, 0, no colour and 0, so a border needs a
|
||||
`width` above 0 to show. `style` is one of `none`, `single`, `double`, `dotted`, `dashed`,
|
||||
`thick`, `triple`, `dotDash`, `wave`.
|
||||
|
||||
### ParaProps
|
||||
|
||||
| Field | Type | Values |
|
||||
|---|---|---|
|
||||
| `style` | string | paragraph style id |
|
||||
| `align` | string | `left`, `center`, `right`, `justify`, `distribute` (`left` is the start edge: the right in a right-to-left paragraph) |
|
||||
| `indentLeft`, `indentRight` | number | points |
|
||||
| `indentFirst` | number | first-line indent, points; negative = hanging |
|
||||
| `indentLeftChars`, `indentRightChars`, `indentFirstChars` | number | the same indents in characters; a non-zero value wins over the one in points, 0 cancels an inherited one |
|
||||
| `spaceBefore`, `spaceAfter` | number | points |
|
||||
| `lineSpacing` | object | `{"rule": "multiple", "value": 1.15}` (1 = single, 2 = double), `{"rule": "atLeast", "value": pt}`, `{"rule": "exactly", "value": pt}` |
|
||||
| `contextualSpacing` | bool | ignore space before/after between paragraphs of the same style |
|
||||
| `mirrorIndents` | bool | left and right indents swap on even pages |
|
||||
| `keepNext`, `keepLines`, `pageBreakBefore`, `widowControl` | bool | |
|
||||
| `outlineLevel` | integer | 0–8 = Level 1–9, 9 = body text |
|
||||
| `numbering` | object | `{"num": n, "level": n}`, both required; `level` counts from 0; `num` 0 removes numbering a style gives |
|
||||
| `tabs` | array | `[{"pos": pt, "align"?: "left\|center\|right\|decimal\|bar\|clear", "leader"?: "none\|dot\|hyphen\|underscore\|middleDot"}]`; replaces the inherited stops, `clear` removes an inherited one at `pos` |
|
||||
| `shading` | `[r, g, b]` | background fill |
|
||||
| `borders` | object | `{"top", "left", "bottom", "right", "between"}`, each a Border (`between`: between paragraphs; `inside_v` is used by tables) |
|
||||
| `suppressHyphens`, `suppressLineNumbers` | bool | |
|
||||
| `bidi` | bool | right-to-left paragraph |
|
||||
| `dropCap` | integer | lines to drop, 0 = none |
|
||||
| `kinsoku`, `wordWrap`, `overflowPunct`, `autoSpaceDE`, `autoSpaceDN`, `snapToGrid` | bool | East Asian typography; unset = on |
|
||||
| `topLinePunct` | bool | East Asian typography; unset = off |
|
||||
| `numChange`, `fmtChange` | | Track Changes bookkeeping |
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**487 of 502 catalog features have commands (97.0%).**
|
||||
**485 of 500 catalog features have commands (97.0%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 22 | 22 |
|
||||
| Quick Access | 5 | 5 |
|
||||
| Title Bar | 1 | 1 |
|
||||
| Home | 102 | 103 |
|
||||
| Insert | 47 | 53 |
|
||||
| Draw | 10 | 11 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously minor: Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
|
|
@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Ribbon/menu catalog coverage | **485 / 500 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
|
||||
| Feature depth (weighted by use, table below) | **~68%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
|
||||
|
|
@ -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). Missing: Asian Typography, kerning/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). 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). Missing: splitting or erasing around vertically merged cells, nested-table polish, table column markers on the ruler (#49). Dragging column and row borders and the resize handle on the page landed (#217), 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). |
|
||||
|
|
@ -251,8 +251,6 @@ can be used without committing Word output.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select whole cells, as in Word; Tables row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Tables: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit on the page; Select Column (catalog 484/499); Tables row updated, no percentage change |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously minor: right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously trivial: grammar proofing: ellipses are not sentence boundaries; previously minor: AutoCorrect's English replacement list applies only to English text, #835; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; previously trivial: outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
|
|
@ -40,7 +40,6 @@ overall picture is in [`target-app-parity.md`](target-app-parity.md).
|
|||
| Enter (tracking on) | the new paragraph mark is a tracked insertion; Reject All joins the paragraphs again, Accept All keeps them | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::reject_all_removes_a_tracked_paragraph_break` |
|
||||
| Paste of several paragraphs (tracking on) | every paragraph mark the paste adds is a tracked insertion; Reject All restores the one paragraph, Accept All keeps the pasted ones | `reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted` |
|
||||
| Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` |
|
||||
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
|
||||
|
||||
## Editing keys
|
||||
|
||||
|
|
@ -168,7 +167,6 @@ Word.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864) |
|
||||
| 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) |
|
||||
| 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) |
|
||||
| 2026-10-12 | trivial | AutoCorrect: untagged text follows the editing language; English decided as proofing does (#835 review) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
|
|
@ -22,14 +22,12 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; Esc leaves a blank document), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 |
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — |
|
||||
| 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; 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); 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. Missing: a single selected cell's border | ✅ | 95% | 1 |
|
||||
| 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); double-click a column border to AutoFit it; 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); 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. Missing: Ctrl and Alt drags, the left edge, borders of selected cells only | ✅ | 90% | 1–2 |
|
||||
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 |
|
||||
| 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, 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% | — |
|
||||
| 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), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 |
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged | reading-optimized view and navigation | 75% | 1–2 |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — |
|
||||
|
|
@ -46,8 +44,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, 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); 80% → 90% |
|
||||
| 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) |
|
||||
| 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them |
|
||||
| 2026-10-11 | trivial | Tables on the page: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells, as in Word |
|
||||
| 2026-10-11 | trivial | Tables on the page: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit; Select Column in the ribbon |
|
||||
|
|
@ -56,7 +52,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| 2026-10-12 | trivial | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819) |
|
||||
| 2026-10-12 | trivial | Ribbon groups fit above their labels: View › Show puts Hide White Space (#645) in a second column instead of a fourth row over the "Show" label; Developer › Controls drops the gaps that pushed it into its label; Layout › Paragraph (#654) puts its column gap inside cells (no `add_space` in the grid, a debug assertion) and 1.5 pt between rows so it fits; `ribbon_groups_fit_above_their_labels` checks every tab in every language |
|
||||
| 2026-10-12 | trivial | Window caption: a file name in a script the interface fonts lack (Hebrew, Arabic, and the rest; CJK via the CJK UI font) is drawn with a fallback face instead of `?` |
|
||||
| 2026-10-12 | trivial | Save Changes prompt: Save has focus when it opens, so Enter, Tab and the arrow keys answer without the mouse (#861) |
|
||||
| 2026-10-12 | trivial | Navigation headings truncate with an ellipsis within the pane width, preserving resizing and full-text hover (#759) |
|
||||
| 2026-10-11 | minor | Reading Mode: viewport-height horizontal columns, animated paging, transient display controls, heading navigation and responsive anchored resizing |
|
||||
| 2026-10-11 | trivial | Alt activation handles native key/modifier events in either order, including key-up with the old Alt flag; taps toggle once, chords and focus loss do not activate KeyTips |
|
||||
|
|
@ -64,8 +59,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| 2026-10-11 | trivial | Ribbon buttons that run a command (alignment, Bold, styles…) hand keyboard focus back to the document, so typing continues without another click; they also report their name and enabled state to screen readers (#686) |
|
||||
| 2026-10-12 | trivial | Keytips use Word for Windows' tab letters, as observed there: File F (opens the Backstage), Design G, References S, Review R, View W, Help Y (were L, R, S, V and none) |
|
||||
| 2026-10-12 | trivial | Shift+F10 opens the page's context menu below the caret, with the spelling suggestions and synonyms for the word there; `ui.contextMenu` for agents (#821). The Menu key itself isn't passed on by the windowing layer yet |
|
||||
| 2026-10-12 | trivial | Search: Alt+Q (and Alt, Q) opens the Search box from anywhere, as in Word; the key's own "q" isn't typed; every word of a query matches, so "insert table" finds Insert › Tables › Table; `tools.search` lists matches for agents (#819) |
|
||||
| 2026-10-12 | trivial | Find, Replace and Go To as in Word (#712): Ctrl+F and Home › Find open only the Navigation pane with its search box ready to type in (the selected text searched for); Find and Replace is one dialog with Find, Replace and Go To tabs (Advanced Find, Ctrl+H, Ctrl+G / F5); Go To moves to a page (a number, or +2 / −1 from the current page; Next and Previous step a page) or a bookmark |
|
||||
| 2026-10-11 | trivial | Tables on the page: column and row borders and the resize handle drag, with a dashed guide and one undo step per drag (#217) |
|
||||
| 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart |
|
||||
| 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents |
|
||||
|
|
@ -83,7 +76,6 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
| 2026-10-11 | trivial | Screen readers: the document canvas exposes paragraphs, headings, lists, tables, links, pictures and the caret through AccessKit (#488); 25% → 50% |
|
||||
| 2026-10-11 | trivial | More Colors… in every colour drop-down and a Colors dialog (Standard and Custom tabs, RGB/HSL numbers, hex box) with Recent Colors (#606) |
|
||||
| 2026-10-11 | trivial | Selection by units: double-click and drag extends by whole words, triple-click and drag by paragraphs, Ctrl/⌘+click and drag by sentences; `select.dragUnit` (#549) |
|
||||
| 2026-10-12 | trivial | Selection bar: clicks in the left margin select the line, paragraph or document, drags select whole lines, Shift+click extends by lines; `select.line {"pos"}`, `select.dragUnit` units `line` and `document` (#856) |
|
||||
| 2026-10-11 | trivial | Compare / Combine Documents (original and revised document, label changes with, comparison settings, word or character level, show changes in) and AutoCorrect Options (replacement list, capitalization, exceptions, AutoFormat As You Type, Math AutoCorrect) dialogs; Restrict Editing pane (formatting and editing restrictions, exceptions for everyone, password); modal dialog rows merged into one (#412) |
|
||||
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
|
||||
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue