Print: preserve page planning and pass native copy collation to spooler

This commit is contained in:
skylar taylor-barrick 2026-10-12 06:50:19 +02:00
commit 1d0dff03a4
100 changed files with 5369 additions and 478 deletions

View File

@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}

1
Cargo.lock generated
View File

@ -5131,6 +5131,7 @@ dependencies = [
"log",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-script",
]
[[package]]

View File

@ -40,6 +40,7 @@ rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
unicode-script = "0.5"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
regex-automata = { version = "0.4", default-features = false, features = ["std", "syntax", "meta", "nfa-pikevm", "unicode"] }

View File

@ -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 (Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (security review fixes landed; 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 | **481 / 496 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **482 / 497 (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) |
@ -112,7 +112,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`) and Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with
system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
@ -132,7 +132,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 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-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
| 2026-10-11 | Design tab: the style set gallery gets a box and gaps of its own, so it no longer runs into Colors and Fonts (#638) |
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
@ -146,7 +152,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
| 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments |
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
| 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-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 |
| 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) |
| 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) |
@ -169,6 +181,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
| 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars |
| 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) |
| 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources |
@ -186,13 +199,19 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 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, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 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. |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
| 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) |
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
| 2026-10-11 | trivial | Progress log: vertical sections (#491) |
| 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) |
| 2026-10-11 | trivial | Progress log: Print Markup (#589) |
| 2026-10-11 | trivial | Progress log: custom colours via More Colors… (#606) |
| 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-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) |
@ -205,6 +224,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |

View File

@ -12,7 +12,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png)
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
@ -74,6 +77,7 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@ -147,13 +151,14 @@ fn run(args: &[String]) -> Result<(), Failure> {
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
match cmd {
"convert" => {
validate_options("convert", &rest, NO_OPTIONS)?;
validate_options("convert", &rest, CONVERT_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1}))
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
s.run("file.save", &json!({"path": o}))
s.run("file.save", &json!({"path": o, "markup": markup}))
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");

View File

@ -26,12 +26,13 @@ pub fn finish_pending_jobs() {
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings| {
Box::new(|bytes, settings: Settings, collate| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
collate,
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,

View File

@ -40,3 +40,10 @@ sync_github_name = false
[people.henno]
sync_github_name = true
[people.DevCoderMax]
real_name = "Gleyson do Nascimento Carvalho"
display_name = "Gleyson Carvalho"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"

236
crates/doc/src/cluster.rs Normal file
View File

@ -0,0 +1,236 @@
//! Text clusters for complex scripts (Myanmar, Khmer, Tai Tham, the Indic scripts): where the
//! caret may stop, what Backspace removes, and where Myanmar text may wrap.
//!
//! Extended grapheme clusters (UAX #29) split some syllables that a shaper draws as one unit: a
//! Myanmar consonant stacked under a virama (`မ္ဘ`), the Myanmar vowel signs U+102B/U+102C and
//! the visarga U+1038 (excluded from the grapheme `SpacingMark` class), Khmer subscript
//! consonants after the coeng. A caret between those would sit inside a glyph cluster, so the
//! caret steps and layout clusters both use [`joins_cluster`] on top of the grapheme rules.
use unicode_segmentation::UnicodeSegmentation;
/// Invisible stackers: the next consonant is drawn under this one (Myanmar virama, Khmer coeng,
/// Tai Tham sakot).
fn is_stacker(c: char) -> bool {
matches!(u32::from(c), 0x1039 | 0x17D2 | 0x1A60)
}
/// Dependent vowel signs, medials, tone marks and other combining signs of Myanmar, Khmer and
/// Tai Tham: never the start of a cluster.
fn is_dependent(c: char) -> bool {
matches!(u32::from(c),
0x102B..=0x103E
| 0x1056..=0x1059
| 0x105E..=0x1060
| 0x1062..=0x1064
| 0x1067..=0x106D
| 0x1071..=0x1074
| 0x1082..=0x108D
| 0x108F
| 0x109A..=0x109D
| 0xA9E5
| 0xAA7B..=0xAA7D
| 0x17B4..=0x17D1
| 0x17D3
| 0x17DD
| 0x1A55..=0x1A5E
| 0x1A61..=0x1A7C
| 0x1A7F)
}
/// No caret stop between `prev` and `next` although a grapheme boundary falls there: `next` is a
/// sign that belongs to the syllable before it, or `prev` stacks the next consonant.
pub fn joins_cluster(prev: char, next: char) -> bool {
is_stacker(prev) || is_dependent(next)
}
/// Is `off` a cluster boundary of `text` (0 and `text.len()` always are)?
pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
if off == 0 || off >= text.len() {
return true;
}
if !text.is_char_boundary(off) {
return false;
}
let mut g = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
if !g.is_boundary(text, 0).unwrap_or(true) {
return false;
}
let prev = text.get(..off).and_then(|t| t.chars().next_back());
let next = text.get(off..).and_then(|t| t.chars().next());
!matches!((prev, next), (Some(p), Some(n)) if joins_cluster(p, n))
}
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
/// first is 0 for non-empty text.
pub fn cluster_starts(text: &str) -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (i, g) in text.grapheme_indices(true) {
let first = g.chars().next();
let joined = matches!((prev, first), (Some(p), Some(n)) if joins_cluster(p, n));
if !joined {
out.push(i);
}
prev = g.chars().next_back();
}
out
}
/// `off` clamped to `text` and moved back to a character boundary.
fn char_floor(text: &str, off: usize) -> usize {
let mut o = off.min(text.len());
while o > 0 && !text.is_char_boundary(o) {
o -= 1;
}
o
}
/// The previous cluster boundary before `off` (0 at the start).
pub fn prev_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.prev_boundary(text, 0).ok().flatten().unwrap_or(0);
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// The next cluster boundary after `off` (`text.len()` at the end).
pub fn next_cluster(text: &str, off: usize) -> usize {
let mut off = char_floor(text, off);
loop {
let mut c = unicode_segmentation::GraphemeCursor::new(off, text.len(), true);
off = c.next_boundary(text, 0).ok().flatten().unwrap_or(text.len());
if is_cluster_boundary(text, off) {
return off;
}
}
}
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
/// South and South-East Asia.
pub fn backspaces_by_char(c: char) -> bool {
matches!(u32::from(c),
0x0900..=0x0DFF // Devanagari … Sinhala
| 0x0E00..=0x0EFF // Thai, Lao
| 0x0F00..=0x0FFF // Tibetan
| 0x1000..=0x109F // Myanmar
| 0x1780..=0x17FF // Khmer
| 0x1A20..=0x1AAF // Tai Tham
| 0x1B00..=0x1B7F // Balinese
| 0xA980..=0xA9FF // Javanese, Myanmar Extended-B
| 0xAA60..=0xAA7F) // Myanmar Extended-A
}
/// A Myanmar character that starts a syllable: a consonant (with the Mon, Shan and Karen ones),
/// an independent vowel, the great sa.
fn myanmar_syllable_start(c: char) -> bool {
matches!(u32::from(c), 0x1000..=0x102A | 0x103F | 0x1050..=0x1055 | 0x105A..=0x105D | 0x1061 | 0x1065..=0x1066 | 0x106E..=0x1070 | 0x1075..=0x1081 | 0x108E)
}
/// Line-break opportunities inside Myanmar text, which has no spaces between words: before each
/// syllable (UAX #14 leaves class SA to a dictionary; Word breaks Myanmar between syllables). A
/// consonant starts a syllable unless it is stacked (after U+1039) or a final (followed, perhaps
/// after a medial, by the asat U+103A, the virama U+1039 or the dot below U+1037). Byte offsets,
/// sorted; only between two Myanmar characters.
pub fn myanmar_breaks(text: &str) -> Vec<usize> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let myanmar = |c: char| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F);
let mut out = Vec::new();
for k in 1..chars.len() {
let (Some(&(i, c)), Some(&(_, prev))) = (chars.get(k), chars.get(k - 1)) else { continue };
if !myanmar(prev) || !myanmar_syllable_start(c) || is_stacker(prev) {
continue;
}
// Skip one medial ya/ha, then look for a killer.
let mut j = k + 1;
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{103B}' | '\u{103E}')) {
j += 1;
}
if chars.get(j).is_some_and(|&(_, n)| matches!(n, '\u{1037}' | '\u{1039}' | '\u{103A}')) {
continue;
}
out.push(i);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// "မြန်မာစာ" (Myanmar script): မြ | န် | မာ | စာ.
const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\u{102C}";
fn split<'a>(s: &'a str, at: &[usize]) -> Vec<&'a str> {
let mut v = Vec::new();
for (n, &a) in at.iter().enumerate() {
let b = at.get(n + 1).copied().unwrap_or(s.len());
v.push(&s[a..b]);
}
v
}
#[test]
fn myanmar_vowel_signs_stay_in_their_syllable() {
// Graphemes alone would cut "မာ" before the aa sign U+102C.
assert_eq!(split(MYANMAR, &cluster_starts(MYANMAR)), vec!["မြ", "န်", "မာ", "စာ"]);
// The prevowel ေ (U+1031) follows its consonant in memory: one cluster, as is visarga း.
assert_eq!(split("ကြေး", &cluster_starts("ကြေး")), vec!["ကြေး"]);
// Stacked consonants (virama U+1039) and kinzi join the syllable they hang from.
let stacked = "\u{1019}\u{1039}\u{1018}\u{102C}"; // မ္ဘာ
assert_eq!(cluster_starts(stacked), vec![0]);
let kinzi = "\u{1021}\u{1004}\u{103A}\u{1039}\u{1002}\u{101C}\u{102D}\u{1015}\u{103A}"; // အင်္ဂလိပ်
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
}
#[test]
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
assert_eq!(split("ខ្មែរ", &cluster_starts("ខ្មែរ")), vec!["ខ្មែ", "រ"]);
// Devanagari conjuncts are grapheme clusters already.
assert_eq!(split("क्षि हि", &cluster_starts("क्षि हि")), vec!["क्षि", " ", "हि"]);
// Latin is untouched: one cluster per letter, combining accents included.
assert_eq!(split("e\u{301}a", &cluster_starts("e\u{301}a")), vec!["e\u{301}", "a"]);
}
#[test]
fn caret_steps_skip_over_whole_clusters() {
let s = MYANMAR;
assert_eq!(next_cluster(s, 0), 6);
assert_eq!(next_cluster(s, 12), 18, "past the aa sign, not before it");
assert_eq!(prev_cluster(s, 18), 12);
assert_eq!(prev_cluster(s, 0), 0);
assert_eq!(next_cluster(s, s.len()), s.len());
assert!(!is_cluster_boundary(s, 15) && is_cluster_boundary(s, 12));
// Hostile offsets: past the end, inside a character.
assert_eq!(next_cluster(s, 1_000), s.len());
assert!(!is_cluster_boundary(s, 1));
assert_eq!(next_cluster(s, 1), 6);
assert_eq!(prev_cluster(s, 13), 6);
assert_eq!(prev_cluster("", 5), 0);
assert_eq!(next_cluster("", 0), 0);
}
#[test]
fn myanmar_wraps_between_syllables() {
// မြန်မာစာ: not before န (န် is a final, killed by the asat), so before မာ and စာ.
assert_eq!(myanmar_breaks(MYANMAR), vec![12, 18]);
// Never before a stacked consonant or one followed by medial + asat.
assert!(myanmar_breaks("\u{1019}\u{1039}\u{1018}\u{102C}").is_empty());
assert!(myanmar_breaks("ကျွန်").is_empty(), "န် is the final of ကျွန်");
// Not between Latin and Myanmar (UAX #14 handles that) or inside Latin.
assert!(myanmar_breaks("abcက").is_empty());
assert!(myanmar_breaks("").is_empty());
}
#[test]
fn backspace_by_character_in_brahmic_scripts_only() {
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
}
}

View File

@ -13,6 +13,7 @@
pub mod bidi;
pub mod chart;
mod cjk_numbers;
pub mod cluster;
pub mod connector;
pub mod control;
pub mod edit;
@ -22,6 +23,7 @@ pub mod eq;
pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod link;
pub mod math;
pub mod math_edit;
pub mod math_latex;

81
crates/doc/src/link.rs Normal file
View File

@ -0,0 +1,81 @@
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
/// space or a control character makes it not a link.
pub fn is_web_or_mail_link(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else { return false };
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
pub fn is_safe_href(url: &str) -> bool {
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !url.trim().is_empty(),
}
}
/// The scheme a browser would see in `url` (RFC 3986 §3.1: a letter, then letters, digits, `+`,
/// `-` or `.`, up to the first `:`), lower-cased; `None` for a relative reference.
fn scheme(url: &str) -> Option<String> {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
let (s, _) = cleaned.split_once(':')?;
let mut chars = s.chars();
let first = chars.next()?;
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
assert!(is_web_or_mail_link(ok), "{ok}");
}
for bad in [
"file:///etc/passwd",
"C:\\Windows\\System32\\calc.exe",
"/usr/bin/xterm",
"javascript:alert(1)",
"ms-msdt:/id",
" https://example.com",
"https\u{0}://example.com",
"",
"example.com",
"#top",
] {
assert!(!is_web_or_mail_link(bad), "{bad:?}");
}
}
/// An exported `href` may be a web/mail link, a bookmark or a relative path: never a script
/// or another scheme, however a browser would normalise it.
#[test]
fn exported_hrefs_allow_bookmarks_and_relative_paths_but_no_scripts() {
for ok in ["https://example.com/x", "mailto:a@b.c", "#top", "other.html", "docs/guide.html#s2", "../up.html", "?q=1"] {
assert!(is_safe_href(ok), "{ok}");
}
for bad in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"java\nscript:alert(1)",
"\tjavascript:alert(1)",
" javascript:alert(1)",
"java\u{0}script:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
}
}
}

View File

@ -1176,19 +1176,24 @@ impl Paragraph {
self.touch();
}
/// Previous grapheme boundary before `off` (0 at start).
/// Previous caret stop before `off` (0 at start): a grapheme boundary that doesn't split a
/// complex-script syllable ([`crate::cluster`]).
pub fn prev_boundary(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
let off = self.clamp(off);
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.prev_boundary(&self.text, 0).ok().flatten().unwrap_or(0)
crate::cluster::prev_cluster(&self.text, self.clamp(off))
}
/// Next grapheme boundary after `off` (len at end).
/// Next caret stop after `off` (len at end), see [`Self::prev_boundary`].
pub fn next_boundary(&self, off: usize) -> usize {
use unicode_segmentation::GraphemeCursor;
crate::cluster::next_cluster(&self.text, self.clamp(off))
}
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
/// at a time, otherwise the previous caret stop.
pub fn backspace_from(&self, off: usize) -> usize {
let off = self.clamp(off);
let mut c = GraphemeCursor::new(off, self.text.len(), true);
c.next_boundary(&self.text, 0).ok().flatten().unwrap_or(self.text.len())
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
Some((i, c)) if crate::cluster::backspaces_by_char(c) => i,
_ => self.prev_boundary(off),
}
}
/// Start of the word at or before `off` (Ctrl+Left).
pub fn word_start(&self, off: usize) -> usize {

View File

@ -138,6 +138,9 @@ pub struct StyleSheet {
/// Document defaults (`w:docDefaults`).
pub default_chr: CharProps,
pub default_para: ParaProps,
/// Preserve an explicit `w:pPrDefault`, even when empty. Omission leaves defaults to
/// the application; nonempty `default_para` is always written after spacing edits.
pub default_para_present: bool,
pub styles: Vec<Style>,
}
@ -165,7 +168,7 @@ fn color(c: Rgb) -> Option<TextColor> {
impl StyleSheet {
pub fn empty() -> Self {
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), styles: Vec::new() }
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
}
/// The built-in style set used for new documents.
@ -179,6 +182,7 @@ impl StyleSheet {
widow_control: Some(true),
..para()
},
default_para_present: true,
styles: Vec::new(),
};
let mut add = |st: Style| s.styles.push(st);
@ -588,6 +592,18 @@ mod tests {
assert!(g.iter().any(|s| s.id == "Heading1"));
}
#[test]
fn older_serialized_styles_keep_explicit_defaults() {
let mut value = serde_json::to_value(StyleSheet::empty()).unwrap();
value.as_object_mut().unwrap().remove("defaultParaPresent");
let sheet: StyleSheet = serde_json::from_value(value).unwrap();
assert!(sheet.default_para_present);
let mut omitted = sheet;
omitted.default_para_present = false;
let again: StyleSheet = serde_json::from_str(&serde_json::to_string(&omitted).unwrap()).unwrap();
assert_eq!(again, omitted);
}
#[test]
fn chain_is_cycle_safe() {
let mut s = StyleSheet::builtin();

View File

@ -31,7 +31,7 @@ pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option<Rgb> {
}
fn is_color(e: &El) -> bool {
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr")
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr" | "a:prstClr" | "a:scrgbClr" | "a:hslClr")
}
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
@ -41,7 +41,25 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let base = match c.name.as_str() {
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
"a:sysClr" => Rgb::parse(c.attr("lastClr")?)?,
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
Some(rgb) => rgb,
None => sys_color(c.attr("val")?)?,
},
"a:prstClr" => preset_color(c.attr("val")?)?,
"a:scrgbClr" => {
// Linear 0–100 000 per channel, to sRGB.
let ch = |n: &str| -> u8 {
let l = c.attr(n).and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(0.0, 1.0));
let s = if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
};
Rgb(ch("r"), ch("g"), ch("b"))
}
"a:hslClr" => {
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
}
_ => return None,
};
let val = |m: &El| m.attr("val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0));
@ -58,6 +76,47 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
Some(rgb)
}
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
/// unknown (`None`).
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
Some(match name {
"black" => Rgb(0, 0, 0),
"white" => Rgb(0xFF, 0xFF, 0xFF),
"red" => Rgb(0xFF, 0, 0),
"green" => Rgb(0, 0x80, 0),
"lime" => Rgb(0, 0xFF, 0),
"blue" => Rgb(0, 0, 0xFF),
"yellow" => Rgb(0xFF, 0xFF, 0),
"cyan" | "aqua" => Rgb(0, 0xFF, 0xFF),
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
"silver" => Rgb(0xC0, 0xC0, 0xC0),
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
"maroon" => Rgb(0x80, 0, 0),
"navy" => Rgb(0, 0, 0x80),
"olive" => Rgb(0x80, 0x80, 0),
"purple" => Rgb(0x80, 0, 0x80),
"teal" => Rgb(0, 0x80, 0x80),
"orange" => Rgb(0xFF, 0xA5, 0),
"brown" => Rgb(0xA5, 0x2A, 0x2A),
"pink" => Rgb(0xFF, 0xC0, 0xCB),
_ => return None,
})
}
/// A system colour without its `lastClr`: the usual light-theme values of the common ones.
fn sys_color(name: &str) -> Option<Rgb> {
Some(match name {
"windowText" | "btnText" | "menuText" | "captionText" | "infoText" => Rgb(0, 0, 0),
"window" | "menu" | "highlightText" => Rgb(0xFF, 0xFF, 0xFF),
"btnFace" | "3dLight" => Rgb(0xF0, 0xF0, 0xF0),
"grayText" | "btnShadow" => Rgb(0xA0, 0xA0, 0xA0),
"highlight" => Rgb(0x00, 0x78, 0xD7),
_ => return None,
})
}
/// Index into `Document::settings.theme_colors` (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink).
fn scheme_slot(name: &str) -> Option<usize> {
Some(match name {

View File

@ -8,6 +8,7 @@ mod freeform;
mod math;
mod props;
mod sdt;
mod shape_style;
mod story;
mod wordart;
@ -641,6 +642,7 @@ impl Reader<'_> {
self.pc.alias.insert(id.to_string(), "Normal".into());
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
if let Some(r) = dd.child("w:rPrDefault").and_then(|d| d.child("w:rPr")) {
sheet.default_chr = self.pc.rpr(r);

View File

@ -0,0 +1,177 @@
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb;
use super::drawing_color::{color, fill_colour};
use crate::units::{int, measure};
use crate::xml::El;
/// Outline widths of the theme's three line styles (`a:lnStyleLst`), points: what Word's
/// default themes give (6 350, 12 700 and 19 050 EMU).
const THEME_LINE_WIDTHS: [f32; 3] = [0.5, 1.0, 1.5];
/// The shape kind drawn for DrawingML preset geometry `prst` (ECMA-376 Part 1 §20.1.10.56).
/// Presets WordCraft has no shape for are drawn as the nearest one it has: callouts as the box,
/// round or cloud they point from, flowchart boxes as rectangles.
pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
match prst {
"roundRect"
| "round1Rect"
| "round2SameRect"
| "round2DiagRect"
| "snipRoundRect"
| "wedgeRoundRectCallout"
| "flowChartAlternateProcess"
| "flowChartTerminator"
| "plaque" => ShapeKind::RoundedRectangle,
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
ShapeKind::Ellipse
}
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
"line" | "lineInv" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
}
}
/// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint {
pub fill: Option<Rgb>,
pub stroke: Option<Rgb>,
/// Points.
pub stroke_width: f32,
}
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an
/// explicit colour wins, and a style reference with index 0 is none.
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint {
let style_ref = |name: &str| -> Option<(i64, &El)> {
let r = style?.child(name)?;
Some((r.attr("idx").and_then(int).unwrap_or(0), r))
};
let fill = match sppr {
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None,
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme),
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)),
};
let ln = sppr.and_then(|s| s.child("a:ln"));
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
let stroke = match ln {
Some(l) if l.child("a:noFill").is_some() => None,
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
_ => line_ref.and_then(|(_, r)| color(r, theme)),
};
let themed_width = line_ref.and_then(|(idx, _)| THEME_LINE_WIDTHS.get(usize::try_from(idx.clamp(1, 3) - 1).unwrap_or(0)).copied());
let stroke_width = ln
.and_then(|l| l.attr("w"))
.and_then(|v| measure(v, 12_700.0))
.or(themed_width)
// Word's default outline when the file names none (no outline: no width).
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
.clamp(0.0, 100.0);
ShapePaint { fill, stroke, stroke_width }
}
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
let v = v.split_whitespace().next()?;
if let Some(hex) = v.strip_prefix('#') {
if hex.len() == 3 && hex.is_ascii() {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Rgb::parse(&long);
}
return Rgb::parse(hex);
}
super::drawing_color::preset_color(v)
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::THEME_COLORS;
fn el(xml: &str) -> El {
let ns = r#"xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
crate::xml::parse(xml.replace(r#"xmlns:wps="w" xmlns:a="a""#, ns).as_bytes()).unwrap()
}
#[test]
fn theme_styled_connector_gets_its_line_from_the_style() {
// A connector with no colour of its own (what Word writes for Insert › Shapes › Line):
// its outline comes from `a:lnRef`, its width from the theme's first line style.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:prstGeom prst="line"/><a:ln><a:tailEnd type="triangle"/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!(p.stroke, Some(THEME_COLORS[7]));
assert_eq!(p.stroke_width, 0.5);
assert_eq!(p.fill, None);
}
#[test]
fn explicit_paint_wins_over_the_style() {
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
// Preset and scheme colours as the box's own fill and outline.
let sp = el(
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
);
let p = shape_paint(Some(&sp), None, &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5));
// A style fill with index 0 is none; an outline set to no fill is none.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke), (None, None));
}
#[test]
fn hostile_style_references_do_not_panic() {
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
);
let p = shape_paint(None, Some(&st), &[]);
assert_eq!((p.fill, p.stroke), (None, None));
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5);
}
#[test]
fn presets_map_to_the_nearest_shape() {
assert_eq!(preset_kind("straightConnector1"), ShapeKind::StraightConnector);
assert_eq!(preset_kind("bentConnector3"), ShapeKind::ElbowConnector);
assert_eq!(preset_kind("wedgeRoundRectCallout"), ShapeKind::RoundedRectangle);
assert_eq!(preset_kind("wedgeEllipseCallout"), ShapeKind::Ellipse);
assert_eq!(preset_kind("wedgeRectCallout"), ShapeKind::Rectangle);
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
#[test]
fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
assert_eq!(vml_color("#é"), None);
assert_eq!(vml_color(""), None);
}
}

View File

@ -13,6 +13,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
use super::Reader;
use super::props::{sectpr, tcpr, trpr};
use super::shape_style::{preset_kind, shape_paint, vml_color};
use crate::package::{Rels, rt};
use crate::units::{int, measure, u32_of};
use crate::xml::El;
@ -1110,31 +1111,16 @@ impl Reader<'_> {
float.set_spin(xfrm_spin(sppr));
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
let txbx = wsp.child("wps:txbx").and_then(|t| t.child("w:txbxContent"));
let mut kind = match prst {
"roundRect" => ShapeKind::RoundedRectangle,
"ellipse" => ShapeKind::Ellipse,
"triangle" | "rtTriangle" => ShapeKind::Triangle,
"diamond" => ShapeKind::Diamond,
"line" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" => ShapeKind::Arrow,
"star5" | "star4" | "star6" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
};
let mut kind = preset_kind(prst);
let is_tb = wsp.child("wps:cNvSpPr").and_then(|c| c.attr("txBox")).is_some_and(|v| v == "1" || v == "true");
if is_tb || (txbx.is_some() && kind == ShapeKind::Rectangle) {
kind = ShapeKind::TextBox;
}
let solid = |e: Option<&El>| {
e.and_then(|f| f.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| c.attr("val")).and_then(Rgb::parse)
};
let fill = solid(sppr);
// Explicit paint, else the theme's through the style references (#611).
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors);
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
let fill = if kind.is_open() { None } else { paint.fill };
let ln = sppr.and_then(|s| s.child("a:ln"));
let stroke = solid(ln);
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
let story = match txbx {
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
@ -1214,32 +1200,34 @@ impl Reader<'_> {
self.doc.add_part(PartKind::TextBox, blocks)
}
/// Legacy VML pictures and text boxes (best effort).
/// Legacy VML pictures, text boxes, lines, rectangles and ovals (best effort).
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?;
let style = shape.attr("style").unwrap_or("");
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
let (w, h) = (len("width"), len("height"));
let float = vml_float(shape, style);
let (mut w, mut h) = (len("width"), len("height"));
let mut float = vml_float(shape, style);
if let Some(img) = shape.find("v:imagedata") {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
let on = |name: &str| shape.attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
if let Some(t) = shape.find("w:txbxContent")
&& sc.story_depth < MAX_STORY_DEPTH
{
let story = Some(self.read_textbox(sc, t, rels));
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
h,
fill,
stroke,
stroke_width: 0.75,
stroke_width,
float,
story,
freeform: None,
@ -1247,7 +1235,51 @@ impl Reader<'_> {
extra: Default::default(),
});
}
None
let kind = match shape.local() {
"line" => ShapeKind::Line,
"rect" => ShapeKind::Rectangle,
"roundrect" => ShapeKind::RoundedRectangle,
"oval" => ShapeKind::Ellipse,
_ => return None,
};
let mut extra = ShapeExtra::default();
if kind == ShapeKind::Line {
// A line runs `from` → `to` (points, offsets from where its style places it).
let pt = |name: &str| -> (f32, f32) {
let v = shape.attr(name).unwrap_or("0,0");
let (x, y) = v.split_once(',').unwrap_or((v, "0"));
let m = |s: &str| measure(s, 1.0).map_or(0.0, wordcraft_geom::finite).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT);
(m(x), m(y))
};
let ((x0, y0), (x1, y1)) = (pt("from"), pt("to"));
let max = crate::units::MAX_LEN_PT;
(w, h) = ((x1 - x0).abs().min(max), (y1 - y0).abs().min(max));
float.x = (float.x + x0.min(x1)).clamp(-max, max);
float.y = (float.y + y0.min(y1)).clamp(-max, max);
// Drawn top-left to bottom-right; flipped for the other directions.
float.flip_h ^= x1 < x0;
float.flip_v ^= y1 < y0;
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
extra.arrow_start = arrow("startarrow");
extra.arrow_end = arrow("endarrow");
}
if stroke.is_none() && (fill.is_none() || kind == ShapeKind::Line) {
return None;
}
Some(InlineObject::Shape {
kind,
w,
h,
fill: fill.filter(|_| kind != ShapeKind::Line),
stroke,
stroke_width,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
})
}
/// An OLE object (`w:object`, ECMA-376 §17.3.3.19): shown as its picture (VML, or a DrawingML

View File

@ -631,11 +631,13 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
props::rpr_inner(&mut w, &s.default_chr);
w.close("w:rPr");
w.close("w:rPrDefault");
w.open("w:pPrDefault", &[]);
w.open("w:pPr", &[]);
props::ppr_inner(&mut w, &s.default_para, false, None);
w.close("w:pPr");
w.close("w:pPrDefault");
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
w.open("w:pPrDefault", &[]);
w.open("w:pPr", &[]);
props::ppr_inner(&mut w, &s.default_para, false, None);
w.close("w:pPr");
w.close("w:pPrDefault");
}
w.close("w:docDefaults");
for st in &s.styles {
style_xml(&mut w, st);

View File

@ -47,6 +47,52 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
// An explicitly empty container must therefore remain distinct from omission.
for (defaults, present, after) in [
("", false, None),
("<w:docDefaults/>", false, None),
("<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val=\"22\"/></w:rPr></w:rPrDefault></w:docDefaults>", false, None),
("<w:docDefaults><w:pPrDefault/></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr><w:spacing w:after=\"160\"/></w:pPr></w:pPrDefault></w:docDefaults>", true, Some(8.0)),
] {
let styles = format!(
r#"<w:styles {W_NS}>{defaults}<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#
);
let mut d = wordcraft_docx::read(&docx(
"<w:p><w:r><w:t>First</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p>",
&[("rId1", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
))
.unwrap();
for _ in 0..2 {
let bytes = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&bytes), "word/styles.xml");
assert_eq!(xml.contains("<w:pPrDefault>"), present, "{defaults}");
d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(d.styles.default_para.space_after, after);
assert_eq!(paras(&d).iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["First", "Second"]);
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert!(!d.styles.default_para_present);
d.styles.default_para.space_after = Some(12.0);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(again.styles.default_para_present);
assert_eq!(again.styles.default_para.space_after, Some(12.0));
let new = Document::new();
assert_eq!(wordcraft_docx::read(&wordcraft_docx::write(&new).unwrap()).unwrap().styles.default_para, new.styles.default_para);
}
fn paras(d: &Document) -> Vec<&Paragraph> {
d.body.iter().filter_map(|b| b.as_para()).collect()
}
@ -1366,3 +1412,80 @@ fn includepicture_keeps_its_embedded_picture() {
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
}
/// One anchored Word shape (`wps:wsp` inside `mc:AlternateContent`, as Word writes them) with
/// shape properties `sppr`, style `style` and extra `wsp` content.
fn anchored_wsp(name: &str, cx: u32, cy: u32, sppr: &str, style: &str, extra: &str) -> String {
format!(
r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>12700</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="{name}"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp>{extra}<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>{sppr}</wps:spPr>{style}<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:line from="0,0" to="1pt,2pt"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"#
)
}
/// #611: diagrams drawn with Word's shapes were mostly missing. Connectors and boxes take their
/// outline and fill from the theme through their style references, or name preset and scheme
/// colours; all of these are read, with line widths and arrowheads.
#[test]
fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
let style = |ln: u8, fill: u8, clr: &str| {
format!(
r#"<wps:style><a:lnRef idx="{ln}"><a:schemeClr val="{clr}"/></a:lnRef><a:fillRef idx="{fill}"><a:schemeClr val="{clr}"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="{clr}"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></wps:style>"#
)
};
let body = [
// A connector with only a style: accent4, the theme's thinnest line, an arrowhead.
anchored_wsp("Arrow", 673100, 889000, r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#, &style(1, 0, "accent4"), "<wps:cNvCnPr/>"),
// A callout text box: scheme fill, preset black outline.
anchored_wsp(
"Text Box",
3073400,
673100,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Callout</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
),
// A filled rectangle from its style alone.
anchored_wsp("Rect", 292100, 647700, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
// An elbow connector.
anchored_wsp("Elbow", 1270000, 635000, r#"<a:prstGeom prst="bentConnector3"><a:avLst/></a:prstGeom>"#, &style(1, 0, "accent2"), "<wps:cNvCnPr/>"),
// A rounded-rectangle callout.
anchored_wsp("Callout", 1270000, 635000, r#"<a:prstGeom prst="wedgeRoundRectCallout"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
]
.concat();
let d = read_body(&format!("<w:p>{body}</w:p>"));
let theme = &d.settings.theme_colors;
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 5);
let shape = |i: usize| match &objs[i] {
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
}
o => panic!("{o:?}"),
};
assert_eq!(shape(0), (ShapeKind::StraightConnector, None, Some(theme[7]), 0.5, true, false));
assert_eq!(shape(1), (ShapeKind::TextBox, Some(theme[1]), Some(Rgb(0, 0, 0)), 0.5, false, true));
assert_eq!(shape(2), (ShapeKind::Rectangle, Some(theme[4]), Some(theme[4]), 1.0, false, false));
assert_eq!(shape(3), (ShapeKind::ElbowConnector, None, Some(theme[5]), 0.5, false, false));
assert_eq!(shape(4).0, ShapeKind::RoundedRectangle);
// The themed outline and the arrowhead survive saving.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(matches!(&paras(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
}
/// #611: legacy VML lines and rectangles (documents without DrawingML).
#[test]
fn vml_lines_and_rectangles() {
let body = r##"<w:p><w:r><w:pict><v:line style="position:absolute;flip:y;z-index:1" from="10pt,20pt" to="40pt,60pt" strokecolor="#4472c4 [3204]" strokeweight=".5pt"><v:stroke endarrow="block"/></v:line></w:pict></w:r>
<w:r><w:pict><v:rect style="position:absolute;margin-left:5pt;margin-top:6pt;width:30pt;height:20pt" fillcolor="#ff0000" stroked="f"/></w:pict></w:r></w:p>"##;
let d = read_body(body);
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 2, "{objs:?}");
match &objs[0] {
InlineObject::Shape { kind: ShapeKind::Line, w, h, stroke, stroke_width, float, extra, .. } => {
assert_eq!((*w, *h, *stroke, *stroke_width), (30.0, 40.0, Some(Rgb(0x44, 0x72, 0xC4)), 0.5));
assert_eq!((float.x, float.y, float.flip_h, float.flip_v), (10.0, 20.0, false, true));
assert!(extra.arrow_end && !extra.arrow_start);
}
o => panic!("{o:?}"),
}
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
}

View File

@ -13,6 +13,7 @@ File|Backstage|Open|file.open
File|Backstage|Save|file.save
File|Backstage|Save As|file.saveAs
File|Backstage|Print|file.print
File|Backstage|Print Markup|file.printMarkup
File|Backstage|Close|file.close
File|Backstage|Info|file.info
File|Backstage|Properties|file.properties

View File

@ -48,6 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
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|paragraph", "start": Pos, "end": Pos, "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| {
@ -287,33 +290,83 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
fn select_word(s: &mut Session, _: &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.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unit {
Word,
Sentence,
Paragraph,
}
impl Unit {
pub fn parse(s: &str) -> Option<Unit> {
match s {
"word" => Some(Unit::Word),
"sentence" => Some(Unit::Sentence),
"paragraph" => Some(Unit::Paragraph),
_ => None,
}
}
}
/// 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 (a, b) = match unit {
Unit::Word => p.word_at(at.off),
Unit::Sentence => p.sentence_at(at.off),
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() })
}
fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.word_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
}
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Word)
}
fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.sentence_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
}
sel_result(s)
select_unit(s, Unit::Sentence)
}
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0);
// Word includes the paragraph mark: extend to the start of the next paragraph when there is one.
let end = match s.doc.next_para(f.story, &f.path) {
Some(q) if q.parent() == f.path.parent() => Pos { story: f.story, path: q, off: 0 },
_ => Pos { off: len, ..f.clone() },
};
s.sel = Selection { anchor: Pos { off: 0, ..f }, focus: end };
select_unit(s, Unit::Paragraph)
}
/// 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 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) };
// Only within one story (the pointer over another story keeps the selection).
if at.story != start.story || end.story != start.story {
return sel_result(s);
}
let (us, ue) = unit_range(s, &at, unit);
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)
}

View File

@ -470,11 +470,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
/// page, to the nearest line and positioned on the page. Returns where its character went.
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => {
(super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true)
}
None => (
super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body)
.ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?,
true,
),
};
s.doc.insert_object(&at, obj, &CharProps::default())?;
let len = OBJ.len_utf8();

View File

@ -35,9 +35,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@ -46,13 +46,26 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
let opts = export_options(s, v);
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
s.prefs.print_markup = p::bool(v, "value").unwrap_or(!s.prefs.print_markup);
Ok(json!({"value": s.prefs.print_markup}))
})
.params(r#"{"value"?: bool (omit to toggle)} — PDFs, prints and copies show comments (balloons beside each page; a list at the end of HTML, Markdown and text) and tracked changes"#)
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
@ -173,7 +186,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
@ -191,11 +204,18 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout();
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
let png = img.to_png();

View File

@ -28,6 +28,16 @@ fn has_floating(s: &Session) -> Option<&'static str> {
_ => Some("select a floating picture or shape first"),
}
}
/// Stories whose objects can be moved, resized and arranged: the body and headers/footers.
/// Objects in table cells (deeper paths), notes and text boxes stay where their text is.
fn movable_story(s: &Session, story: StoryRef) -> bool {
match story {
StoryRef::Body => true,
StoryRef::Part(id) => {
s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer))
}
}
}
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
@ -688,9 +698,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
/// position afterwards.
fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
use wordcraft_doc::para::Anchor;
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text can be moved".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
}
let story = pos.story;
let layout = s.layout();
let cur = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
let page = page.map_or(cur.page, |n| usize::try_from(n).unwrap_or(usize::MAX));
@ -698,19 +709,19 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
// Keep a corner of it on the page.
let x = x.max(12.0 - w).min(pg.w - 12.0);
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
let (anchor, on_page) = match anchor_paragraph(&layout, page, y) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => (nearest_line(&layout, page, y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) {
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.path == pos.path && !on_page {
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
pos
} else {
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = anchor;
if at.path == pos.path && at.off > pos.off {
if at.story == pos.story && at.path == pos.path && at.off > pos.off {
at.off = at.off.saturating_sub(len);
}
let at = s.doc.clamp(&at);
@ -798,31 +809,33 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else
/// the first starting on the page).
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<wordcraft_doc::Path> {
let starts: Vec<(&wordcraft_doc::Path, f32)> = layout
.pages
.get(page)?
.items
.iter()
/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above
/// it (else the first starting on the page). Body paragraphs come from the page's items;
/// header/footer ones from its header and footer.
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<wordcraft_doc::Path> {
let pg = layout.pages.get(page)?;
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
StoryRef::Body => Box::new(pg.items.iter()),
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
};
let starts: Vec<(&wordcraft_doc::Path, f32)> = items
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, l0: 0, y: top, .. } if path.depth() == 0 => Some((path, *top)),
wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)),
_ => None,
})
.collect();
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
}
/// The start of the top-level body line on `page` nearest to `y`.
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body);
/// The start of the top-level line of `story` on `page` nearest to `y`.
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, story);
let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| {
let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) };
d(a).total_cmp(&d(b))
})?;
let off = best.para.lines.get(best.li)?.start;
Some(Pos { story: StoryRef::Body, path: best.path.clone(), off })
Some(Pos { story, path: best.path.clone(), off })
}
fn obj_size(o: &InlineObject) -> (f32, f32) {
@ -1103,7 +1116,7 @@ fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => super::parse_pos(v),
};
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
pos.filter(|p| movable_story(s, p.story) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
@ -1155,9 +1168,13 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
picks.sort();
let layout = s.layout();
let mut found = Vec::new();
let story = picks.first().cloned().map(|p| p.story).ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
if picks.iter().any(|p| p.story != story) {
return Err(CmdError::Disabled("objects in different stories (body, header, footer) can't be grouped".into()));
}
for pos in &picks {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only objects in the body text, headers and footers (not in tables) can be grouped".into()));
}
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
if matches!(obj, InlineObject::Graphic { .. }) {
@ -1241,8 +1258,8 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
// Its offsets, explicit: the members are placed from the same anchor.
let pos = if float.wrap == Wrap::Inline {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only groups in the body text, headers and footers can be ungrouped in line with text".into()));
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
@ -1698,6 +1715,34 @@ mod tests {
assert_eq!(crop, [0.1, 0.2, 0.05, 0.0]);
}
/// A header picture resizes and drags like a body one: the move stays in the header story
/// and lands where dropped; the header hit test finds it.
#[test]
fn header_pictures_can_be_resized_and_dragged() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.editHeader", &json!({})).unwrap();
let story = s.sel.focus.story;
assert!(matches!(story, StoryRef::Part(_)), "editing the header, got {story:?}");
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
let (pos, _) = selected(&s).expect("a new header picture is selected");
assert_eq!(pos.story, story);
s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap();
let r = s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 60})).unwrap();
assert_eq!(r["object"]["float"]["wrap"], "square");
let o = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert_eq!(o.story, story);
assert!((o.rect.x - 200.0).abs() < 1.0 && (o.rect.y - 60.0).abs() < 1.0, "{:?}", o.rect);
let hit = s.layout().object_at_in(o.page, o.rect.x + 5.0, o.rect.y + 5.0, 4.0, story).expect("header hit");
assert_eq!(hit.pos(), o.pos());
s.run("arrange.nudge", &json!({"dx": 6, "dy": 4})).unwrap();
let n = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert!((n.rect.x - 206.0).abs() < 1.0 && (n.rect.y - 64.0).abs() < 1.0, "{:?}", n.rect);
s.run("edit.undo", &json!({})).unwrap();
let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect);
}
/// Shape Format › Text (#369): direction and alignment on the box the caret is in, then
/// Create Link to an empty box and Break Link, each one undo step.
#[test]

View File

@ -60,7 +60,7 @@ fn cell_boxes(l: &DocLayout, page: usize) -> Vec<CellBox> {
pg.items
.iter()
.filter_map(|it| match it {
Placed::Cell { rect, table, row, cell, story } => {
Placed::Cell { rect, table, row, cell, story, .. } => {
Some(CellBox { rect: *rect, table: table.clone(), story: *story, row: *row, cell: *cell })
}
_ => None,
@ -527,6 +527,29 @@ mod tests {
assert!(s.run("table.draw", &json!({})).is_err());
}
/// Erasing a horizontal border merges the cells into one laid-out cell (#640): the lower
/// row's box is `covered`, the merged cell's box spans both rows, and erasing the border
/// below the merge extends it.
#[test]
fn erasing_a_horizontal_border_lays_the_merge_out_as_one_cell() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap();
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 0));
s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap();
let t = first_table(&s);
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
let merged = cell_rect(&mut s, 0, 0);
assert!((merged.bottom() - b.bottom()).abs() < 0.5 && merged.h > a.h * 1.9, "{merged:?}");
let covered = s.layout().pages[0].items.iter().any(|it| matches!(it, Placed::Cell { row: 1, cell: 0, covered: true, .. }));
assert!(covered, "the continuation row's box is covered");
let hit = s.layout().cell_at(0, merged.x + 3.0, b.y + b.h / 2.0).map(|c| (c.2, c.3));
assert_eq!(hit, Some((0, 0)), "a click in the lower half is in the merged cell");
// The border below the merge: the merge grows to three rows.
s.run("table.eraser", &json!({"page": 0, "x": merged.x + merged.w / 2.0, "y": merged.bottom() + 0.5})).unwrap();
let t = first_table(&s);
assert_eq!(t.rows[2].cells[0].props.vmerge, VMerge::Continue);
}
/// A vertical line through a cell splits it into two columns; a horizontal one splits a row;
/// the eraser merges the cells back; each is one undo step.
#[test]

View File

@ -502,7 +502,7 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
if f.off > 0 {
let a = Pos { off: para.prev_boundary(f.off), ..f.clone() };
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
return sel_result(s);

View File

@ -27,6 +27,10 @@ pub fn specs() -> Vec<CommandSpec> {
// The body as an outline of its headings, with the Outlining tab (`outline.*`).
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(),
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
// The drawing grid over the page (screen only; never printed or exported).

View File

@ -27,9 +27,11 @@ pub const OPEN_EXTS: &[&str] = &[
/// Formats WordCraft saves (Save As).
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
/// The extension of a file name or path, lower-case; empty when the file name has none (a dot
/// in a folder name doesn't count).
fn ext_of(name: &str) -> String {
let lower = name.to_ascii_lowercase();
lower.rsplit('.').next().unwrap_or("").to_string()
let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default()
}
/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss?
@ -37,6 +39,17 @@ pub fn is_word_package(ext: &str) -> bool {
wordcraft_docx::Flavor::from_ext(ext).is_some()
}
/// How a copy in another format is written.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExportOptions {
/// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled
/// down beside a markup area with the comments in balloons (page images get the area too);
/// HTML, Markdown and text copies end with the comments.
pub markup: bool,
/// With `markup`, show the comments (Track Changes Options › Comments).
pub comments: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Password(zeroize::Zeroizing<String>);
@ -94,27 +107,64 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
doc.ensure_nonempty();
return Ok((doc, true));
}
if ext.is_empty() {
return open_by_content(name, &ext, bytes, password);
}
let mut doc = match ext.as_str() {
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
"txt" | "text" | "" => wordcraft_formats::txt::import(bytes),
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
other => match crate::io_ext::open(other, bytes) {
Some(r) => r?,
None => return Err(format!("{name}: unsupported format `.{other}`")),
None => return open_by_content(name, other, bytes, password),
},
};
doc.ensure_nonempty();
Ok((doc, false))
}
/// 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
/// refused with a clear error rather than shown as garbage.
fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
const ZIP: &[u8] = b"PK\x03\x04";
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
let (mut doc, encrypted) = if bytes.starts_with(OLE) && wordcraft_docx::is_encrypted(bytes) {
(wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true)
} else if bytes.starts_with(ZIP) {
(wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
} else if bytes.starts_with(OLE) {
(wordcraft_docbin::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
} else if bytes.starts_with(b"{\\rtf") {
(wordcraft_formats::rtf::import(bytes)?, false)
} else if wordcraft_formats::txt::is_text(bytes) {
(wordcraft_formats::txt::import(bytes), false)
} else if ext.is_empty() {
return Err(format!("{name}: not a document or text file WordCraft can open"));
} else {
return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)"));
};
doc.ensure_nonempty();
Ok((doc, encrypted))
}
/// Serialise a document; `name` gives the format by extension.
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
save_bytes_opts(name, doc, &ExportOptions::default())
}
/// [`save_bytes`] with export options (Print Markup).
pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let ext = ext_of(name);
match ext.as_str() {
// List paragraphs get their label and a tab, as in Word's plain-text save.
"txt" | "text" => Ok(wordcraft_formats::txt::export(doc)),
"txt" | "text" => {
let bytes = wordcraft_formats::txt::export(doc);
Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes })
}
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
other => match crate::io_ext::save(other, doc) {
other => match crate::io_ext::save(other, doc, opts) {
Some(r) => r,
None => Err(format!("{name}: can't save as `.{other}`")),
},
@ -123,8 +173,8 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package
/// (other formats are copies and are written as they are).
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>) -> Result<Vec<u8>, String> {
let bytes = save_bytes(name, doc)?;
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let bytes = save_bytes_opts(name, doc, opts)?;
match password {
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
@ -263,13 +313,13 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
}
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
save_path_with(path, doc, None)
save_path_with(path, doc, None, &ExportOptions::default())
}
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password)?;
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
// Write atomically: temp file next to the target, then rename.
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
@ -277,7 +327,7 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
}
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}

View File

@ -14,22 +14,27 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
}
/// Save to a format other than plain text / JSON.
pub fn save(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option<Result<Vec<u8>, String>> {
// .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects.
if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) {
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
}
match ext {
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0)),
"pdf" => Some(wordcraft_pdf::export(doc, &Default::default()).map_err(|e| e.to_string())),
other => wordcraft_formats::export(other, doc),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
let pdf = wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, ..Default::default() };
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
}
}
/// Render page `page` as PNG at `scale` px/pt.
pub fn render_png(doc: &Document, page: usize, scale: f32) -> Result<Vec<u8>, String> {
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
/// markup area with the comments in balloons (page images always show tracked changes).
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())

View File

@ -12,6 +12,7 @@ pub mod io;
mod io_ext;
pub mod keymap;
pub mod math_gallery;
pub mod print;
pub mod ribbon;
pub mod sample;
mod session;

218
crates/engine/src/print.rs Normal file
View File

@ -0,0 +1,218 @@
//! Print settings (#641): which pages go out, how many copies, as a PDF.
//!
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
//!
//! Page ranges and copies come from users and control channels, so they are bounded: a range
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
//! resulting page list never exceeds [`MAX_PRINT_PAGES`].
use serde_json::{Value, json};
use wordcraft_layout::DocLayout;
use crate::{CmdError, Session, p};
/// Longest page-range text accepted.
pub const MAX_SPEC_CHARS: usize = 500;
/// Most copies one print job asks for.
pub const MAX_COPIES: u32 = 999;
/// Most pages (counting copies) one print job holds.
pub const MAX_PRINT_PAGES: usize = 5000;
/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed
/// (Word prints them in document order; so does this: sorted, without repeats). Pages are
/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text,
/// page 0, a page past `total`, a reversed range or anything else is an error.
pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String> {
if spec.chars().count() > MAX_SPEC_CHARS {
return Err("the page range is too long".into());
}
if total == 0 {
return Err("the document has no pages".into());
}
let mut keep = vec![false; total];
let mut any = false;
for item in spec.split([',', ';']) {
let item: String = item.chars().filter(|c| !c.is_whitespace()).collect();
if item.is_empty() {
continue;
}
let page = |t: &str| -> Result<usize, String> {
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
if n == 0 || n > total {
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
}
Ok(n)
};
let (from, to) = match item.split_once('-') {
None => {
let n = page(&item)?;
(n, n)
}
Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()),
Some((a, b)) => {
let from = if a.is_empty() { 1 } else { page(a)? };
let to = if b.is_empty() { total } else { page(b)? };
if from > to {
return Err(format!("`{item}` runs backwards"));
}
(from, to)
}
};
for flag in keep.iter_mut().take(to).skip(from - 1) {
*flag = true;
}
any = true;
}
if !any {
return Err("type the pages to print, like 1-3, 5".into());
}
Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect())
}
/// What a print job holds: the pages (0-based, copies included, in print order).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrintPlan {
/// Page indexes in print order, repeated for copies.
pub pages: Vec<usize>,
/// Distinct pages chosen (before copies).
pub chosen: usize,
pub copies: u32,
}
/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection
/// sit on pages `current` (first, last). Params:
/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`),
/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999),
/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3).
pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result<PrintPlan, CmdError> {
let bad = |m: String| CmdError::Params(m);
let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty());
let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" });
let last = total.saturating_sub(1);
let mut pages: Vec<usize> = match range {
"all" => (0..total).collect(),
"current" => vec![current.0.min(last)],
"selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(),
"custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?,
other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))),
};
match p::str(v, "parity").unwrap_or("all") {
"all" => {}
// Page numbers are 1-based: the odd ones are indexes 0, 2, 4…
"odd" => pages.retain(|i| i % 2 == 0),
"even" => pages.retain(|i| i % 2 == 1),
other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))),
}
if pages.is_empty() {
return Err(bad("no pages to print with these settings".into()));
}
let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32;
let chosen = pages.len();
let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES);
let Some(total_out) = total_out else {
return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold")));
};
let mut out = Vec::with_capacity(total_out);
if p::bool(v, "collate").unwrap_or(true) {
for _ in 0..copies {
out.extend_from_slice(&pages);
}
} else {
for &pg in &pages {
out.extend(std::iter::repeat_n(pg, copies as usize));
}
}
Ok(PrintPlan { pages: out, chosen, copies })
}
impl Session {
/// The pages the caret (or selection) is on, in the output layout: (first, last).
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
let (a, b) = self.sel.ordered();
let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page);
let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page);
(first, last.max(first))
}
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
/// distinct pages chosen and copies. Does not touch the document.
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
let opts = wordcraft_pdf::PdfOptions {
pages: Some(plan.pages.clone()),
// Repeated pages would repeat the structure tree's entries too.
tagged: plan.copies <= 1,
include_markup: markup.markup,
comments: markup.comments,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
Ok((bytes, plan))
}
}
/// `file.printPdf`: write the print job's PDF to `path` (no dialogs).
pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result<Value, CmdError> {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
let (bytes, plan) = s.print_pdf(v)?;
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_parse_and_hostile_ones_are_errors() {
assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4]));
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
}
assert!(parse_page_range("1", 0).is_err());
}
#[test]
fn plans_apply_range_parity_copies_and_caps() {
let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages);
assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5]));
assert_eq!(p(json!({"range": "current"})), Ok(vec![2]));
assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3]));
assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3]));
assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2]));
assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5]));
assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages");
assert!(p(json!({"range": "everything"})).is_err());
assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left");
// Hostile copies are clamped; a job that would be too big is refused, not allocated.
assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize));
assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err());
assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2]));
}
#[test]
fn print_pdf_writes_only_the_chosen_pages() {
let mut s = Session::new(crate::sample::sample_document());
let pages = s.export_layout().pages.len();
let (all, _) = s.print_pdf(&json!({})).unwrap();
let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap();
assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF"));
assert_eq!(plan.pages.len(), 2);
if pages > 2 {
assert!(one.len() < all.len());
}
assert!(s.print_pdf(&json!({"pages": "9999"})).is_err());
let r = s.run("file.printPdf", &json!({})).is_err();
assert!(r, "printPdf needs a path");
}
}

View File

@ -95,6 +95,11 @@ pub struct ViewState {
/// Show field codes instead of their results (`view.fieldCodes`, Alt+F9).
#[serde(default)]
pub field_codes: bool,
/// Print Layout › Hide White Space: pages run together, the top and bottom margins (with
/// headers and footers) between pages collapse to a thin line. On screen only; the layout
/// and page count are unchanged.
#[serde(default)]
pub hide_white_space: bool,
/// Outline view: collapsed headings, Show Level, first lines, text formatting.
#[serde(default)]
pub outline: crate::cmd::outline::OutlineState,
@ -136,6 +141,7 @@ impl Default for ViewState {
hide_ink: false,
draw: Default::default(),
field_codes: false,
hide_white_space: false,
outline: Default::default(),
design_mode: false,
}
@ -335,6 +341,10 @@ pub struct Prefs {
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
/// File › Print / Export: Print Markup. PDFs, prints and page images show comments in
/// balloons beside each page and tracked changes as marked up; HTML, Markdown and text
/// copies end with the comments (`file.printMarkup`).
pub print_markup: bool,
/// Language › Detect language automatically (per user, as in Word).
pub detect_language: bool,
/// Typing over a selection replaces it; off, the typed text goes in front of it.
@ -363,6 +373,7 @@ impl Default for Prefs {
Prefs {
count_notes: true,
markup: Default::default(),
print_markup: false,
typing_replaces_selection: true,
insert_key_overtype: false,
overtype: false,
@ -537,6 +548,7 @@ impl Session {
show_hidden: self.view.marks || self.prefs.marks.hidden,
hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(),
proofing: self.view.proofing,
comment_balloons: false,
field_codes: self.view.field_codes,
outline: outline.clone(),
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
@ -547,7 +559,13 @@ impl Session {
}
/// A layout for output (PDF, images, print): no proofing marks, print view.
pub fn export_layout(&self) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, ..Default::default() };
self.export_layout_with(false)
}
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
/// area with the comments in balloons.
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, comment_balloons: markup && self.prefs.markup.comments, ..Default::default() };
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
}

View File

@ -3218,6 +3218,191 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
/// Print Markup (#589): `file.printMarkup` or a command's `markup` puts the comments into PDFs
/// (balloons beside the scaled page) and at the end of HTML, Markdown and text copies.
#[test]
fn print_markup_exports_comments() {
let mut s = Session::new(wordcraft_doc::Document::from_text("The quarterly figures look strong."));
s.author = "Ada Reviewer".into();
s.run("select.text", &json!({"text": "figures"})).unwrap();
let id = s.run("review.newComment", &json!({"text": "Check the totals"})).unwrap()["id"].as_u64().unwrap();
s.run("review.reply", &json!({"id": id, "text": "Totals checked"})).unwrap();
let opts = |s: &Session, v| cmd::file::export_options(s, &v);
assert!(!opts(&s, json!({})).markup, "off by default");
let pdf = |s: &Session, v| crate::io::save_bytes_opts("x.pdf", &s.doc, &opts(s, v)).unwrap();
let html = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.html", &s.doc, &opts(s, v)).unwrap()).unwrap();
let txt = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.txt", &s.doc, &opts(s, v)).unwrap()).unwrap();
assert!(!html(&s, json!({})).contains("Check the totals"));
assert!(!txt(&s, json!({})).contains("Check the totals"));
let plain = pdf(&s, json!({}));
// The setting turns it on for every copy; a command's `markup` overrides it.
assert_eq!(s.run("file.printMarkup", &json!({})).unwrap()["value"], true);
let h = html(&s, json!({}));
assert!(
h.contains("<section class=\"comments\">") && h.contains("Ada Reviewer") && h.contains("<q>figures</q>") && h.contains("Totals checked"),
"{h}"
);
assert!(h.find("Check the totals") < h.find("</body>"));
let t = txt(&s, json!({}));
assert!(t.contains("[1] Ada Reviewer on “figures”: Check the totals") && t.contains("Totals checked"), "{t}");
let md = String::from_utf8(crate::io::save_bytes_opts("x.md", &s.doc, &opts(&s, json!({}))).unwrap()).unwrap();
assert!(md.contains("## Comments") && md.contains("1. **Ada Reviewer**"), "{md}");
assert_ne!(pdf(&s, json!({})), plain);
assert!(!html(&s, json!({"markup": false})).contains("Check the totals"));
// Comments hidden in Track Changes Options stay out.
s.prefs.markup.comments = false;
assert!(!html(&s, json!({})).contains("Check the totals"));
s.prefs.markup.comments = true;
assert_eq!(s.run("file.printMarkup", &json!({"value": false})).unwrap()["value"], false);
assert!(html(&s, json!({"markup": true})).contains("Check the totals"));
// file.exportPdf and file.exportPng take it too.
let dir = std::env::temp_dir().join(format!("wordcraft-print-markup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("m.pdf");
let r = s.run("file.exportPdf", &json!({"path": path.to_string_lossy(), "markup": true})).unwrap();
assert_eq!(r["markup"], true);
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
let png = dir.join("m.png");
let wide = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "markup": true, "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
let narrow = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
assert!(wide > narrow + 200, "{wide} vs {narrow}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #606: the colour commands behind More Colors… take any `RRGGBB` (with or without `#`, any
/// case), not only the palette's colours; junk is a params error and changes nothing.
#[test]
fn colour_commands_take_any_custom_hex() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Custom"}));
run(&mut s, "select.all", json!({}));
for (given, want) in [("7A3EC8", "7A3EC8"), ("#0b5d1e", "0B5D1E"), (" ff8800 ", "FF8800")] {
run(&mut s, "format.color", json!({"color": given}));
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!(want)));
}
let doc = s.doc.clone();
for junk in [json!("#12"), json!("purple"), json!("GG0000"), json!(12), json!(null), json!("ééé")] {
assert!(s.run("format.color", &json!({"color": junk})).is_err(), "{junk}");
}
assert_eq!(s.doc, doc);
run(&mut s, "format.color", json!({"color": "auto"}));
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!("auto")));
// Shading and page colour take custom colours the same way.
run(&mut s, "para.shading", json!({"color": "#C0FFEE"}));
let shading = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.shading);
assert_eq!(shading, wordcraft_doc::Rgb::parse("C0FFEE"));
run(&mut s, "design.pageColor", json!({"color": "123ABC"}));
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
}
/// #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.
#[test]
fn unknown_text_extensions_open_as_text() {
let lines = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
let m3u8 = "#EXTM3U\r\n#EXTINF:215,Björk — Jóga\r\nmusic/Jóga.mp3\r\n#EXTINF:-1,Радио\r\nhttp://example.org/live\r\n";
let doc = crate::io::open_bytes("Mix.M3U8", m3u8.as_bytes()).unwrap();
assert!(
lines(&doc).starts_with("#EXTM3U\n#EXTINF:215,Björk — Jóga\nmusic/Jóga.mp3\n#EXTINF:-1,Радио\nhttp://example.org/live"),
"{:?}",
lines(&doc)
);
assert!(!lines(&doc).contains('\r'));
// With a BOM, which must not show up as a character.
let bom = [&[0xEF, 0xBB, 0xBF][..], m3u8.as_bytes()].concat();
assert!(lines(&crate::io::open_bytes("a.m3u8", &bom).unwrap()).starts_with("#EXTM3U\n"));
// A plain .m3u in Windows-1252 (é = E9, — = 97).
let cp1252 = b"#EXTM3U\r\n#EXTINF:100,Caf\xE9 \x97 Live\r\nC:\\Music\\caf\xE9.mp3\r\n";
assert_eq!(lines(&crate::io::open_bytes("old.m3u", cp1252).unwrap()), "#EXTM3U\n#EXTINF:100,Café — Live\nC:\\Music\\café.mp3\n");
// Other text files, a file without an extension, a dot in a folder name only.
for name in ["server.log", "settings.ini", "subs.srt", "README", "/home/a.b/README", "C:\\v1.2\\notes"] {
assert_eq!(lines(&crate::io::open_bytes(name, b"one\ntwo").unwrap()), "one\ntwo", "{name}");
}
// Binary files: an error naming the file, no panic.
for (name, bytes) in
[("song.mp3", &b"ID3\x04\0\0\0\0\x01\x02\x03"[..]), ("pic.dat", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), ("blob", &[0u8, 1, 2, 3, 0, 255])]
{
let err = crate::io::open_bytes(name, bytes).unwrap_err();
assert!(err.contains(name) && err.contains("not a document"), "{name}: {err}");
}
assert!(crate::io::open_bytes("x.pdf", b"%PDF-1.7\n1 0 obj\n").is_err());
// A Word document or RTF file with the wrong extension opens by what it holds.
let docx = crate::io::save_bytes("x.docx", &crate::sample::letter()).unwrap();
assert_eq!(lines(&crate::io::open_bytes("letter.bak", &docx).unwrap()), lines(&crate::io::open_bytes("x.docx", &docx).unwrap()));
let rtf = crate::io::open_bytes("note.tmp", br"{\rtf1\ansi Hello {\b bold}\par}").unwrap();
assert!(lines(&rtf).starts_with("Hello bold"), "{:?}", lines(&rtf));
// Through file.open, both from disk and with the bytes (the web's picker and drops).
let dir = std::env::temp_dir().join(format!("wordcraft-569-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("road trip.m3u");
std::fs::write(&path, cp1252).unwrap();
let mut s = s();
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!(r["paragraphs"], 4);
assert!(text(&s).contains("Café — Live"));
let data = crate::cmd::insert::base64_encode(m3u8.as_bytes());
run(&mut s, "file.open", json!({"path": "Mix.m3u8", "data": data}));
assert!(text(&s).contains("Радио"));
let bad = crate::cmd::insert::base64_encode(b"\0\x01\x02binary\0");
assert!(s.run("file.open", &json!({"path": "x.bin", "data": bad})).is_err());
assert!(text(&s).contains("Радио"), "a failed open leaves the document alone");
let _ = std::fs::remove_dir_all(&dir);
}
/// #549: after a double-click, dragging extends the selection by whole words; after a
/// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep
/// the first unit's start as the anchor, backward drags its end.
#[test]
fn drag_after_multi_click_extends_by_units() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Alpha beta gamma. Delta epsilon.\rSecond para here.\rThird one."}));
let drag = |s: &mut Session, unit: &str, start: Pos, end: Pos, pos: Pos| {
run(s, "select.dragUnit", json!({"unit": unit, "start": start, "end": end, "pos": pos}));
(s.sel.anchor.clone(), s.sel.focus.clone())
};
// Double-click "beta" (6..11 with its trailing space).
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 7)}));
run(&mut s, "select.word", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.off, b.off), (6, 11));
// Forward into the middle of "gamma": the whole word joins.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 13)), (Pos::body(0, 6), Pos::body(0, 16)));
// Back into "Alpha": anchored at the end of "beta ", focus at the start of "Alpha".
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 2)), (Pos::body(0, 11), Pos::body(0, 0)));
// Back inside the first word: just that word.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 8)), (Pos::body(0, 6), Pos::body(0, 11)));
// Into the next paragraph: whole word there.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(1, 8)), (Pos::body(0, 6), Pos::body(1, 12)));
// Ctrl+click a sentence, drag into the next sentence.
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 3)}));
run(&mut s, "select.sentence", json!({}));
let (a, b) = s.sel.ordered();
let (_, f) = drag(&mut s, "sentence", a, b, Pos::body(0, 20));
assert_eq!(f.off, 32, "the second sentence is selected to its end");
// Triple-click the second paragraph, drag up into the first and down into the third.
run(&mut s, "caret.set", json!({"pos": Pos::body(1, 3)}));
run(&mut s, "select.paragraph", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.clone(), b.clone()), (Pos::body(1, 0), Pos::body(2, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(0, 5)), (Pos::body(2, 0), Pos::body(0, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(2, 2)), (Pos::body(1, 0), Pos::body(2, 10)));
// Junk never panics and leaves the document alone.
let before = text(&s);
for v in [
json!({}),
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}}),
] {
let _ = s.run("select.dragUnit", &v);
}
assert_eq!(text(&s), before);
}
/// 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.
@ -3403,6 +3588,17 @@ fn note_options_per_section_and_convert_notes() {
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
}
#[test]
fn hide_white_space_toggles_view_state_only() {
let mut s = s();
let before = s.doc.body.len();
assert!(!s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true);
assert!(s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false);
assert_eq!(s.doc.body.len(), before, "a view option never edits the document");
}
/// The first SmartArt (or other graphic) in the body, selected.
fn select_graphic(s: &mut Session) {
let (path, off) = (0..s.doc.body.len() as u32)

View File

@ -489,6 +489,47 @@ fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true));
}
/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn
/// before it), the caret steps over whole syllables and Backspace removes one sign at a time.
#[test]
fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() {
// ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ.
let word = "\u{1000}\u{103C}\u{1031}\u{1038}";
let mut s = typed(word);
assert_eq!(paras(&s), vec![para(word, None)]);
// Backspace takes the visarga, then the vowel sign: the consonant and medial stay.
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}");
typ(&mut s, "\u{8}\u{1031}\u{1038}");
assert_eq!(paras(&s)[0].0, word, "retyped");
// Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme
// extenders, so a grapheme step would stop inside it).
let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 6);
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 0);
run(&mut s, "caret.right", json!({}));
assert_eq!(s.sel.focus.off, 6);
// Delete removes the whole syllable after the caret.
run(&mut s, "text.delete", json!({}));
assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}");
}
/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct.
#[test]
fn indic_and_khmer_backspace_removes_one_character() {
let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}");
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}");
let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫
assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}");
// Latin keeps grapheme Backspace: e + combining acute goes as one.
let s = typed("ae\u{301}\u{8}");
assert_eq!(paras(&s)[0].0, "a");
}
/// Press a shortcut the way the keyboard does: run the command bound to it.
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));

View File

@ -13,4 +13,5 @@ kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
unicode-script = { workspace = true }
log = { workspace = true }

View File

@ -188,6 +188,12 @@ impl FontFace {
}
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
}
/// Does the face carry OpenType glyph substitution (GSUB)? Complex scripts (Myanmar, Khmer,
/// Indic…) need it to reorder and stack; fonts with only Apple's AAT tables draw them unshaped.
pub fn has_layout_tables(&self) -> bool {
use skrifa::raw::TableProvider;
self.skrifa().is_some_and(|f| f.gsub().is_ok())
}
/// Units per em.
pub fn units_per_em(&self) -> f64 {
self.upem
@ -300,6 +306,12 @@ pub struct FontDb {
/// System fallback state: enabled, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
/// Held through a system fallback search, so searches run one at a time. Without it a search
/// racing another that was still loading a preferred family ("Padauk" for Myanmar) skipped
/// that family as already tried and loaded whatever the coverage index found first (only
/// Padauk Bold), so which face a character fell back to depended on thread timing.
#[cfg(not(target_arch = "wasm32"))]
searching: Mutex<()>,
/// The characters each installed font file maps, read the first time a fallback search
/// needs that file. Held through a search, so two searches never read a file twice.
#[cfg(not(target_arch = "wasm32"))]
@ -835,6 +847,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
#[cfg(not(target_arch = "wasm32"))]
searching: Mutex::new(()),
#[cfg(not(target_arch = "wasm32"))]
coverage: Mutex::new(CoverageIndex::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog_gen: AtomicU32::new(0),
@ -1270,9 +1284,13 @@ impl FontDb {
return hit.clone();
}
}
let found = self.search_fallback(c, exclude);
// Keyed by the face count after the search (a system fallback may have loaded fonts).
let n = self.read_faces().len();
let (found, n) = self.search_fallback(c, exclude);
// Keyed by the face count the answer was chosen from (a system fallback may have loaded
// fonts, and another thread may have loaded more since: an answer from before that load
// is returned but not cached under the new count).
if n != self.read_faces().len() {
return found;
}
let mut cache = self.fallbacks.write().unwrap_or_else(|e| e.into_inner());
if cache.faces != n || cache.map.len() >= FALLBACK_CACHE_MAX {
cache.map.clear();
@ -1282,17 +1300,36 @@ impl FontDb {
found
}
fn search_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
self.loaded_fallback(c, exclude).or_else(|| {
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback(c) {
return self.loaded_fallback(c, exclude);
}
None
})
/// The fallback face for `c`, and the number of loaded faces it was chosen from.
fn search_fallback(&self, c: char, exclude: u32) -> (Option<Arc<FontFace>>, usize) {
let (found, n) = self.loaded_fallback(c, exclude);
if found.is_some() {
return (found, n);
}
self.system_search(c, exclude)
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
/// [`Self::search_fallback`] when no loaded face covers `c`: load a system font that does,
/// one search at a time.
#[cfg(not(target_arch = "wasm32"))]
fn system_search(&self, c: char, exclude: u32) -> (Option<Arc<FontFace>>, usize) {
let _searching = self.searching.lock().unwrap_or_else(|e| e.into_inner());
// A search that held the lock first may have loaded a font covering `c`: choose among the
// loaded faces again before loading more.
let (found, n) = self.loaded_fallback(c, exclude);
if found.is_none() && self.system_fallback(c) {
return self.loaded_fallback(c, exclude);
}
(found, n)
}
#[cfg(target_arch = "wasm32")]
fn system_search(&self, _c: char, _exclude: u32) -> (Option<Arc<FontFace>>, usize) {
(None, self.read_faces().len())
}
/// The best loaded face covering `c`, and the number of loaded faces it was chosen from.
fn loaded_fallback(&self, c: char, exclude: u32) -> (Option<Arc<FontFace>>, usize) {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
// Right-to-left letters prefer a font made for the script, so a word isn't split across
@ -1304,8 +1341,12 @@ impl FontDb {
0
}
};
order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
// Complex scripts (Myanmar, Khmer, Indic…): a font with OpenType layout tables, preferring
// the script's own families, so syllables reorder and stack (#604).
let complex = crate::needs_layout_tables(c);
let shaping = |f: &FontFace| if complex { (!f.has_layout_tables(), crate::fallback_rank(c, &f.family)) } else { (false, 0) };
order.sort_by_key(|f| (rank(f), shaping(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
(order.into_iter().find(|f| f.covers(c)).cloned(), faces.len())
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
@ -1326,7 +1367,7 @@ impl FontDb {
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let rtl = crate::is_rtl(c) || arabic_block(c);
let preferred = RTL_FALLBACKS.iter().filter(|_| rtl);
for fam in preferred.chain(SYSTEM_FALLBACKS.iter()) {
for fam in crate::script_fallbacks(c).iter().chain(preferred).chain(SYSTEM_FALLBACKS.iter()) {
// Each family is tried once: one that fails to load isn't read again per character.
if self.is_loaded(fam) || !cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
continue;

View File

@ -9,6 +9,7 @@
mod fontdb;
pub mod math;
mod script;
mod ui_fallback;
pub mod word;
@ -16,6 +17,7 @@ pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LocalName, base_sty
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
pub use kurbo::BezPath;
pub use script::{fallback_rank, needs_layout_tables, script_fallbacks};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
@ -180,21 +182,27 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
// One script per shaping call: the shaper picks its script engine (Myanmar, Khmer,
// Indic, USE reordering…) from the run's script, so "Hi မြန်မာ" in one face must not be
// shaped as Latin. Runs come in text order; right-to-left callers regroup by cluster.
for r in script::script_runs(text) {
let Some(sub) = text.get(r.clone()) else { continue };
let mut buf = UnicodeBuffer::new();
for (i, c) in sub.char_indices() {
buf.add(map(c), (r.start + i) as u32);
}
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
}
});
if shaped.is_none()

135
crates/fonts/src/script.rs Normal file
View File

@ -0,0 +1,135 @@
//! Scripts: itemising text into single-script runs for the shaper, and the fonts preferred when
//! a document's font lacks a complex script (Myanmar, Khmer, the Indic scripts, Thai…).
//!
//! A shaper picks its script-specific engine (the Myanmar, Khmer, Indic or Universal Shaping
//! Engine reordering, e.g. U+1031 ေ drawn before its consonant) from the run's script, so text
//! mixing scripts in one face must be shaped one script at a time.
#![allow(clippy::single_range_in_vec_init)] // one run is a list of runs
use unicode_script::{Script, UnicodeScript};
/// The script of `c` for itemisation: `None` for characters that take the script of the text
/// around them (spaces, punctuation, digits, combining marks, joiners).
fn real_script(c: char) -> Option<Script> {
match c.script() {
Script::Common | Script::Inherited | Script::Unknown => None,
// Japanese and Chinese mix these within a word: one run.
Script::Hiragana | Script::Katakana | Script::Bopomofo => Some(Script::Han),
s => Some(s),
}
}
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
if text.is_ascii() {
return vec![0..text.len()];
}
let mut runs = Vec::new();
let mut cur: Option<Script> = None;
let mut start = 0;
for (i, c) in text.char_indices() {
let Some(s) = real_script(c) else { continue };
match cur {
Some(p) if p != s => {
runs.push(start..i);
start = i;
cur = Some(s);
}
None => cur = Some(s),
_ => {}
}
}
runs.push(start..text.len());
runs
}
/// Scripts whose shaping reorders, stacks or forms clusters (OpenType script-specific engines),
/// so a fallback font must carry OpenType layout tables to draw them properly.
pub fn needs_layout_tables(c: char) -> bool {
matches!(
c.script(),
Script::Myanmar
| Script::Khmer
| Script::Devanagari
| Script::Bengali
| Script::Gurmukhi
| Script::Gujarati
| Script::Oriya
| Script::Tamil
| Script::Telugu
| Script::Kannada
| Script::Malayalam
| Script::Sinhala
| Script::Thai
| Script::Lao
| Script::Tibetan
| Script::Tai_Tham
| Script::Balinese
| Script::Javanese
| Script::Arabic
| Script::Syriac
)
}
/// Families preferred, in order, for a character of `c`'s script when the document's font lacks
/// it: the platforms' own OpenType fonts and the open Noto/SIL families. Apple's older fonts for
/// these scripts carry only AAT tables (which the shaper doesn't read), so they come last.
pub fn script_fallbacks(c: char) -> &'static [&'static str] {
match c.script() {
Script::Myanmar => &["Myanmar Text", "Noto Sans Myanmar", "Padauk", "Pyidaungsu", "Noto Serif Myanmar", "Myanmar Sangam MN", "Myanmar MN"],
Script::Khmer => &["Leelawadee UI", "Khmer UI", "Noto Sans Khmer", "Noto Serif Khmer", "Khmer OS", "Khmer Sangam MN", "Khmer MN"],
Script::Devanagari => &["Nirmala UI", "Mangal", "Kohinoor Devanagari", "Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari"],
Script::Bengali => &["Nirmala UI", "Vrinda", "Kohinoor Bangla", "Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali"],
Script::Thai => &["Leelawadee UI", "Leelawadee", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Serif Thai"],
Script::Lao => &["Leelawadee UI", "Lao UI", "Noto Sans Lao", "Noto Serif Lao", "Lao Sangam MN"],
Script::Tamil | Script::Telugu | Script::Kannada | Script::Malayalam | Script::Gujarati | Script::Gurmukhi | Script::Oriya => &["Nirmala UI"],
_ => &[],
}
}
/// How well `family` suits `c` as a fallback: 0 for the first preferred family, then the others
/// in order; then families named after the script ("Noto Sans Tamil"); then everything else.
pub fn fallback_rank(c: char, family: &str) -> usize {
let list = script_fallbacks(c);
if let Some(i) = list.iter().position(|n| family.eq_ignore_ascii_case(n)) {
return i;
}
if needs_layout_tables(c) {
let name = c.script().full_name().replace('_', " ");
if family.to_ascii_lowercase().contains(&name.to_ascii_lowercase()) {
return list.len();
}
}
list.len() + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runs_split_by_script_and_neutrals_join_their_neighbours() {
assert_eq!(script_runs("Hello"), vec![0..5]);
// "Hi မြန်မာ!" → Latin "Hi ", Myanmar "မြန်မာ!".
let s = "Hi မြန်မာ!";
let runs = script_runs(s);
assert_eq!(runs, vec![0..3, 3..s.len()]);
// A leading neutral joins the first run; marks never start a run.
assert_eq!(script_runs("(ကေ)"), vec![0.."(ကေ)".len()]);
// Japanese kana and kanji stay together.
assert_eq!(script_runs("日本語のテキスト").len(), 1);
assert_eq!(script_runs(""), vec![0..0]);
}
#[test]
fn complex_scripts_prefer_their_own_fonts() {
assert!(needs_layout_tables('မ') && needs_layout_tables('ក') && needs_layout_tables('क'));
assert!(!needs_layout_tables('a') && !needs_layout_tables('語'));
assert_eq!(fallback_rank('မ', "Myanmar Text"), 0);
assert!(fallback_rank('မ', "Noto Sans Myanmar") < fallback_rank('မ', "Myanmar Sangam MN"));
assert!(fallback_rank('த', "Noto Sans Tamil") < fallback_rank('த', "Arial"));
assert_eq!(fallback_rank('a', "Arial"), fallback_rank('a', "Noto Sans"));
}
}

View File

@ -36,6 +36,24 @@ fn substitutes(family: &str) -> &'static [&'static str] {
// headings and screens. Open fonts with full Persian coverage first.
const NASKH: &[&str] = &["Noto Naskh Arabic", "XB Zar", "XB Niloofar", "Amiri", "Vazirmatn", "Noto Sans Arabic", "DejaVu Sans", "Source Serif 4"];
const ARABIC_SANS: &[&str] = &["Vazirmatn", "Sahel", "Shabnam", "Samim", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Source Sans 3"];
// Palatino Linotype / Book Antiqua: macOS ships the same design as "Palatino"; open
// look-alikes with matching metrics (TeX Gyre Pagella, URW P052 / Palladio) on Linux.
const PALATINO: &[&str] = &[
"Palatino Linotype",
"Palatino",
"Book Antiqua",
"TeX Gyre Pagella",
"P052",
"URW Palladio L",
"Palladio",
"Caladea",
"Times New Roman",
"Liberation Serif",
"Georgia",
"Noto Serif",
"DejaVu Serif",
"Source Serif 4",
];
let f = family.to_ascii_lowercase();
if is_persian_naskh(&f) {
return NASKH;
@ -44,6 +62,7 @@ fn substitutes(family: &str) -> &'static [&'static str] {
return ARABIC_SANS;
}
match f.as_str() {
"palatino linotype" | "book antiqua" | "palatino" => PALATINO,
"arial" | "helvetica" | "arial nova" => ARIAL,
"times new roman" | "times" => TIMES,
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
@ -52,8 +71,6 @@ fn substitutes(family: &str) -> &'static [&'static str] {
| "cambria math"
| "georgia"
| "garamond"
| "book antiqua"
| "palatino linotype"
| "constantia"
| "baskerville old face"
| "century"
@ -250,6 +267,19 @@ mod tests {
assert!(a > 0.0 && d >= 0.0);
}
#[test]
fn palatino_family_prefers_palatino_look_alikes_over_generic_serif() {
for name in ["Palatino Linotype", "Book Antiqua", "palatino"] {
let list = substitutes(name);
let pos = |n: &str| list.iter().position(|x| *x == n);
assert!(pos("Palatino").is_some() && pos("TeX Gyre Pagella").is_some() && pos("P052").is_some(), "{name}");
// All of them come before the generic serif fallbacks (Caladea, Times New Roman).
assert!(pos("Palatino") < pos("Caladea") && pos("TeX Gyre Pagella") < pos("Times New Roman"), "{name}");
}
// Other serif families are unchanged.
assert_eq!(substitutes("Georgia").first(), Some(&"Caladea"));
}
#[test]
fn symbol_font_chars_map_to_unicode() {
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));

View File

@ -0,0 +1,200 @@
//! Print Markup for the text formats: HTML, Markdown and plain text copies end with the
//! document's comments, numbered in document order, each with its author, the text it comments
//! on, what it says and its replies (like the notes at the end of a printed document).
use std::collections::{BTreeMap, HashMap, HashSet};
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::{Document, StoryRef};
/// Longest quoted anchor text (characters).
const MAX_QUOTE: usize = 200;
const MAX_COMMENTS: usize = 5_000;
const MAX_REPLIES: usize = 200;
const MAX_PARENT_CHAIN: usize = 64;
/// One comment as written out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
pub author: String,
/// The text it comments on (shortened), or empty.
pub quote: String,
pub text: String,
/// (author, text) of each reply, oldest first.
pub replies: Vec<(String, String)>,
}
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
}
}
cur
}
fn author(a: &str) -> String {
if a.trim().is_empty() { "Author".to_string() } else { a.trim().to_string() }
}
fn text_of(doc: &Document, part: u32) -> String {
doc.plain_text(StoryRef::Part(part)).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The document's top-level comments: those anchored in the body in document order, then the
/// rest; replies go under the comment they answer.
pub fn notes(doc: &Document) -> Vec<Note> {
if doc.comments.is_empty() {
return Vec::new();
}
// Anchor order and the text between each comment's start and end.
let mut order: Vec<u32> = Vec::new();
let mut seen = HashSet::new();
let mut quotes: HashMap<u32, String> = HashMap::new();
let mut open: Vec<u32> = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (off, c) in p.text.char_indices() {
if c == OBJ {
match p.object_at(off) {
Some(InlineObject::CommentStart { id }) => {
if seen.insert(*id) {
order.push(*id);
}
if !open.contains(id) && open.len() < 64 {
open.push(*id);
}
}
Some(InlineObject::CommentEnd { id }) => open.retain(|x| x != id),
_ => {}
}
continue;
}
for id in &open {
let q = quotes.entry(*id).or_default();
if q.chars().count() <= MAX_QUOTE {
q.push(c);
}
}
}
for id in &open {
quotes.entry(*id).or_default().push(' ');
}
}
for id in doc.comments.keys() {
if seen.insert(*id) {
order.push(*id);
}
}
let mut replies: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for id in doc.comments.keys() {
let r = root(doc, *id);
if r != *id {
let v = replies.entry(r).or_default();
if v.len() < MAX_REPLIES {
v.push(*id);
}
}
}
let mut out = Vec::new();
for id in order {
let Some(c) = doc.comments.get(&id) else { continue };
if root(doc, id) != id {
continue;
}
let quote = quotes.get(&id).map(|q| q.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default();
let quote = if quote.chars().count() > MAX_QUOTE { format!("{}…", quote.chars().take(MAX_QUOTE).collect::<String>()) } else { quote };
let replies =
replies.get(&id).into_iter().flatten().filter_map(|r| doc.comments.get(r)).map(|r| (author(&r.author), text_of(doc, r.part))).collect();
out.push(Note { author: author(&c.author), quote, text: text_of(doc, c.part), replies });
if out.len() >= MAX_COMMENTS {
break;
}
}
out
}
/// `bytes` (a copy in format `ext`) with the comments written at its end.
pub fn append(ext: &str, doc: &Document, bytes: Vec<u8>) -> Vec<u8> {
let notes = notes(doc);
if notes.is_empty() {
return bytes;
}
match ext {
"html" | "htm" | "xhtml" => {
let mut s = match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => return e.into_bytes(),
};
let at = s.rfind("</body>").unwrap_or(s.len());
s.insert_str(at, &html(&notes));
s.into_bytes()
}
"md" | "markdown" => {
let mut s = bytes;
s.extend_from_slice(markdown(&notes).as_bytes());
s
}
"txt" | "text" => {
let mut s = bytes;
s.extend_from_slice(text(&notes).as_bytes());
s
}
_ => bytes,
}
}
fn html(notes: &[Note]) -> String {
use crate::html::esc;
let mut s = String::from("<section class=\"comments\">\n<h2>Comments</h2>\n<ol>\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("<li id=\"comment-{}\"><p><strong>{}</strong>", i + 1, esc(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on <q>{}</q>", esc(&n.quote)));
}
s.push_str(&format!(": {}</p>", esc(&n.text)));
if !n.replies.is_empty() {
s.push_str("<ul>");
for (a, t) in &n.replies {
s.push_str(&format!("<li><strong>{}</strong>: {}</li>", esc(a), esc(t)));
}
s.push_str("</ul>");
}
s.push_str("</li>\n");
}
s.push_str("</ol>\n</section>\n");
s
}
fn markdown(notes: &[Note]) -> String {
use crate::markdown::escape;
let mut s = String::from("\n\n## Comments\n\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("{}. **{}**", i + 1, escape(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", escape(&n.quote)));
}
s.push_str(&format!(": {}\n", escape(&n.text)));
for (a, t) in &n.replies {
s.push_str(&format!(" - **{}**: {}\n", escape(a), escape(t)));
}
}
s
}
fn text(notes: &[Note]) -> String {
let mut s = String::from("\r\n\r\nComments\r\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("\r\n[{}] {}", i + 1, n.author));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", n.quote));
}
s.push_str(&format!(": {}\r\n", n.text));
for (a, t) in &n.replies {
s.push_str(&format!(" {a}: {t}\r\n"));
}
}
s
}

View File

@ -1004,9 +1004,7 @@ impl<'r> Builder<'r> {
}
}
"a" => {
if let Some(h) =
attr(attrs, "href").filter(|h| !h.trim().is_empty() && !h.trim_start().to_ascii_lowercase().starts_with("javascript:"))
{
if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) {
fmt.link = Some(h.trim().to_string());
}
}
@ -1205,7 +1203,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
Inline::Text(t, f) => {
let mut open = String::new();
let mut close: Vec<&str> = Vec::new();
if let Some(l) = &f.link {
// A script or other non-web target is written as plain text (`is_safe_href`).
if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
open.push_str(&format!("<a href=\"{}\">", esc(l)));
close.push("</a>");
}

View File

@ -9,6 +9,7 @@
//! depth-limited and sizes are capped.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod comments;
pub mod html;
pub mod latex;
pub mod markdown;
@ -55,5 +56,24 @@ pub fn export(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
})
}
/// How [`export_with`] writes a copy.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExportOptions {
/// Print Markup: HTML, Markdown and plain text end with the document's comments (who wrote
/// each, the text it comments on, what it says, its replies). Other formats keep comments
/// their own way or not at all.
pub comments: bool,
}
/// [`export`] with options.
pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option<Result<Vec<u8>, String>> {
let out = export(ext, doc)?;
if !opts.comments {
return Some(out);
}
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
Some(out.map(|bytes| comments::append(&ext, doc, bytes)))
}
#[cfg(test)]
mod tests;

View File

@ -1013,7 +1013,7 @@ fn emit(mut nodes: Vec<Node>) -> Vec<Inline> {
// Export
/// Escape text for Markdown (inline context).
fn escape(s: &str) -> String {
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
@ -1125,8 +1125,9 @@ fn inlines_md(inlines: &[Inline]) -> String {
}
};
for (text, f, raw) in pieces {
// Links are the outermost construct.
if f.link != link || raw.is_some() {
// Links are the outermost construct; a script or other non-web target is plain text.
let f_link = f.link.clone().filter(|l| wordcraft_doc::link::is_safe_href(l));
if f_link != link || raw.is_some() {
close_all(&mut out, &mut stack);
if let Some(l) = &link {
out.push_str(&format!("]({})", link_target(l)));
@ -1136,9 +1137,9 @@ fn inlines_md(inlines: &[Inline]) -> String {
out.push_str(&r);
continue;
}
if let Some(l) = &f.link {
if let Some(l) = f_link {
out.push('[');
link = Some(l.clone());
link = Some(l);
}
}
let want: Vec<Mark> =

View File

@ -280,6 +280,26 @@ fn txt_round_trip() {
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
}
/// #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]
fn text_sniffing() {
use crate::txt::is_text;
assert!(is_text(b""));
assert!(is_text(b"#EXTM3U\r\n#EXTINF:123,Artist - Title\r\nC:\\Music\\song.mp3\r\n"));
assert!(is_text("#EXTM3U\n#EXTINF:-1,Мелодия — ünïcødé\nhttp://x/y.mp3\n".as_bytes()));
assert!(is_text(&[0x23, 0x45, 0x58, 0x54, 0xE9, 0x0D, 0x0A, 0x1A])); // Windows-1252, DOS ^Z
assert!(is_text(&[0xEF, 0xBB, 0xBF, b'a']));
let u16le: Vec<u8> = "#EXTM3U\r\n".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
assert!(is_text(&u16le));
assert!(!is_text(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"));
assert!(!is_text(b"PK\x03\x04\x14\0\x06\0"));
assert!(!is_text(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
assert!(!is_text(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 3, 4, 5, 6, 7, 8]));
// Odd-length bytes with zeros mixed in: binary, not UTF-16.
assert!(!is_text(&[1, 0, 2, 0, 3]));
}
#[test]
fn dispatch_unknown() {
assert!(import("xyz", b"").is_none());
@ -590,3 +610,47 @@ fn bounded_scans_keep_ordinary_markdown_and_entities() {
let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default());
assert!(t.contains(&"a".repeat(40)), "{t}");
}
/// A document's hyperlink can be `javascript:` (WordCraft refuses to follow it); the exported
/// HTML and Markdown must not hand it to a browser either. Web, mail, bookmark and relative links
/// still export as links.
#[test]
fn exported_links_keep_only_safe_targets() {
let links = [
("script", "javascript:alert(1)"),
("split", "java\nscript:alert(1)"),
("vb", "vbscript:MsgBox(1)"),
("data", "data:text/html,<b>x</b>"),
("web", "https://ok.example/x"),
("mail", "mailto:a@b.example"),
("bookmark", "#top"),
("relative", "other.html"),
];
let mut d = Document::new();
let mut p = Paragraph::with_text("", CharProps::default());
for (text, l) in links {
let n = p.len();
p.insert_text(n, text, &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap();
let n = p.len();
p.insert_text(n, " ", &CharProps::default()).unwrap();
}
d.body = vec![para_block(p)];
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap();
for bad in ["javascript", "vbscript", "data:"] {
assert!(!html.to_ascii_lowercase().contains(bad), "html: {html}");
assert!(!md.to_ascii_lowercase().replace('\n', "").contains(bad), "md: {md}");
}
for (text, _) in links {
assert!(html.contains(text), "html keeps the text {text:?}: {html}");
assert!(md.contains(text), "md keeps the text {text:?}: {md}");
}
for (text, l) in &links[4..] {
assert!(html.contains(&format!("<a href=\"{l}\">{text}</a>")), "html: {html}");
assert!(md.contains(&format!("[{text}]({l})")), "md: {md}");
}
for text in ["script", "split", "vb", "data"] {
assert!(!html.contains(&format!(">{text}</a>")), "html: {html}");
assert!(!md.contains(&format!("[{text}](")), "md: {md}");
}
}

View File

@ -13,6 +13,53 @@ fn utf16(b: &[u8], le: bool) -> String {
String::from_utf16_lossy(&units)
}
/// UTF-16 without a BOM, told by its zero bytes: `Some(true)` little-endian, `Some(false)`
/// big-endian.
fn unmarked_utf16(b: &[u8]) -> Option<bool> {
if b.len() < 4 || !b.len().is_multiple_of(2) {
return None;
}
let sample = b.get(..b.len().min(512))?;
let even_zero = sample.iter().step_by(2).filter(|x| **x == 0).count();
let odd_zero = sample.iter().skip(1).step_by(2).filter(|x| **x == 0).count();
let half = sample.len() / 2;
if odd_zero * 10 >= half * 4 && even_zero * 10 < half {
return Some(true);
}
if even_zero * 10 >= half * 4 && odd_zero * 10 < half {
return Some(false);
}
None
}
/// Does `b` look like plain text, so a file with an extension WordCraft doesn't know (a
/// playlist, a log, a config file…) can open as text the way Word's text import opens it?
///
/// Text has a BOM, or reads as UTF-16 by its zero bytes, or has no NUL byte and next to no
/// other control characters in its first 64 KB. PDFs are refused by their signature (they
/// start as text). An empty file is text.
pub fn is_text(b: &[u8]) -> bool {
if b.starts_with(&[0xEF, 0xBB, 0xBF]) || b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) {
return true;
}
if b.starts_with(b"%PDF-") {
return false;
}
let sample = b.get(..b.len().min(64 << 10)).unwrap_or(b);
// Tab, line feed, form feed, carriage return, DOS end-of-file (^Z) and escape appear in text;
// other controls in more than 1 of 100 characters mean binary.
let odd = |c: u32| c == 0 || (c < 0x20 && !matches!(c, 0x09 | 0x0A | 0x0C | 0x0D | 0x1A | 0x1B)) || c == 0x7F;
if let Some(le) = unmarked_utf16(sample) {
let units = sample.as_chunks::<2>().0;
let n = units.iter().filter(|c| odd(u32::from(if le { u16::from_le_bytes(**c) } else { u16::from_be_bytes(**c) }))).count();
return n.saturating_mul(100) <= units.len();
}
if sample.contains(&0) {
return false;
}
sample.iter().filter(|c| odd(u32::from(**c))).count().saturating_mul(100) <= sample.len()
}
/// Decode text bytes: BOMs first, then UTF-16 without BOM (by its zero bytes), UTF-8, and
/// Windows-1252 as the last resort.
pub fn decode(b: &[u8]) -> String {
@ -25,17 +72,8 @@ pub fn decode(b: &[u8]) -> String {
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
return utf16(rest, false);
}
if b.len() >= 4 && b.len().is_multiple_of(2) {
let sample = &b[..b.len().min(512)];
let even_zero = sample.iter().step_by(2).filter(|x| **x == 0).count();
let odd_zero = sample.iter().skip(1).step_by(2).filter(|x| **x == 0).count();
let half = sample.len() / 2;
if odd_zero * 10 >= half * 4 && even_zero * 10 < half {
return utf16(b, true);
}
if even_zero * 10 >= half * 4 && odd_zero * 10 < half {
return utf16(b, false);
}
if let Some(le) = unmarked_utf16(b) {
return utf16(b, le);
}
match std::str::from_utf8(b) {
Ok(s) => s.to_string(),

View File

@ -0,0 +1,235 @@
//! Print Markup: the comments in balloons in a markup area on the right of each page, joined to
//! the text they comment on, the way Word prints and exports a document "showing markup".
//!
//! [`crate::layout`] calls [`add`] when [`crate::LayoutOptions::comment_balloons`] is on: each
//! page grows by [`MARKUP_AREA`] ([`crate::Page::markup`]) and gets, for each comment anchored on
//! it, a card (author colour stripe and border) holding the author's name and the comment's own
//! laid-out text, with its replies under it, and a leader from the anchor to the card. The cards
//! are ordinary placed items, so the PDF writer, the renderer and print draw them unchanged
//! (the PDF writer scales each page down to its paper size). The editor draws its own interactive
//! balloons instead.
//!
//! Hostile documents: comment, reply and parent-chain counts are capped; cards that don't fit
//! are clipped to the page, and when a page has more comments than its markup area can hold the
//! ones that find no room are left out.
use std::collections::{BTreeMap, HashSet};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{Border, BorderStyle, CharProps, Rgb, TextColor};
use wordcraft_doc::{Document, Paragraph, Pos, StoryRef};
use wordcraft_geom::Rect;
use crate::display::revision_color;
use crate::{BoxLayout, Ctx, DocLayout, Placed, fit_box, layout_box, push_para};
/// Width of the markup area beside each page (points): the editor's.
pub const MARKUP_AREA: f32 = 216.0;
/// Space between the page and the cards, and on their right.
const SIDE: f32 = 9.0;
/// Space between cards.
const GAP: f32 = 6.0;
/// Padding inside a card.
const PAD: f32 = 5.0;
/// The author-colour stripe on a card's left.
const STRIPE: f32 = 3.0;
/// Space kept above the first and below the last card on a page.
const EDGE: f32 = 12.0;
/// Smallest card height when cards have to be clipped to fit a page.
const MIN_CARD: f32 = 18.0;
const MAX_BALLOONS: usize = 5_000;
const MAX_REPLIES: usize = 200;
const MAX_PARENT_CHAIN: usize = 64;
/// Path given to the synthetic author-name paragraphs: no paragraph of the comment's story has it.
const HEADER_PATH: u32 = u32::MAX;
/// One comment's balloon before it is positioned: items relative to the card's top-left.
struct Card {
page: usize,
/// The anchor: the comment's start, at the foot of its line.
ax: f32,
ay: f32,
items: Vec<Placed>,
h: f32,
color: Rgb,
}
/// The thread a comment belongs to: the comment at the top of its parent chain.
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
}
}
cur
}
/// Each top-level comment anchored in the body, with where it starts, in document order. Replies
/// have no balloon of their own: they go under the comment they answer.
pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
let Some(InlineObject::CommentStart { id }) = p.object_at(off) else { continue };
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
continue;
}
out.push((*id, Pos { story: StoryRef::Body, path: path.clone(), off }));
if out.len() >= MAX_BALLOONS {
return out;
}
}
}
out
}
/// Replies by the top-level comment they belong to, oldest (lowest id) first.
pub fn replies(doc: &Document) -> BTreeMap<u32, Vec<u32>> {
let mut m: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for id in doc.comments.keys() {
let r = root(doc, *id);
if r != *id {
let v = m.entry(r).or_default();
if v.len() < MAX_REPLIES {
v.push(*id);
}
}
}
m
}
/// The comment authors in order of first appearance (for their colours).
fn author_index(doc: &Document, author: &str) -> u32 {
let mut seen: Vec<&str> = Vec::new();
for c in doc.comments.values() {
if !seen.contains(&c.author.as_str()) {
seen.push(&c.author);
}
}
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
}
fn author_name(a: &str) -> &str {
if a.trim().is_empty() { "Author" } else { a }
}
/// Widen every page by the markup area and place the comment balloons in it.
pub(crate) fn add(ctx: &mut Ctx, lay: &mut DocLayout) {
let doc = ctx.doc;
if doc.comments.is_empty() || lay.pages.is_empty() {
return;
}
let anchors = anchors(doc);
if anchors.is_empty() {
return;
}
let threads = replies(doc);
let card_w = MARKUP_AREA - 2.0 * SIDE;
let inner = card_w - STRIPE - 2.0 * PAD;
let mut cards: Vec<Card> = Vec::new();
for (id, pos) in anchors {
let Some(caret) = lay.caret_on(&pos, 0) else { continue };
let Some(c) = doc.comments.get(&id) else { continue };
let color = revision_color(author_index(doc, &c.author));
let mut thread = vec![id];
thread.extend(threads.get(&id).into_iter().flatten().copied());
let x = STRIPE + PAD;
let mut items = Vec::new();
let mut y = PAD;
for (k, cid) in thread.iter().enumerate() {
let Some(cm) = doc.comments.get(cid) else { continue };
let story = StoryRef::Part(cm.part);
if k > 0 {
// A reply starts: a hairline between it and what it answers.
y += 3.0;
let border = Border { style: BorderStyle::Single, width: 0.25, color: Some(Rgb(0xC8, 0xC8, 0xC8)), space: 0.0 };
items.push(Placed::Rule { x0: x, y0: y, x1: x + inner, y1: y, border });
y += 3.0;
}
// Who wrote it, in their colour.
let head = Paragraph::with_text(
author_name(&cm.author),
CharProps { bold: Some(true), size: Some(9.0), color: Some(TextColor::Rgb(color)), ..Default::default() },
);
let pl = ctx.para_labelled(&head, inner, None, &[], None);
let lead = pl.lines.first().map_or(0.0, |l| l.top.max(0.0));
push_para(&mut items, story, &[HEADER_PATH], &pl, 0, pl.lines.len(), x, y + lead, inner);
y += pl.height + 2.0;
// The comment itself, as written (its own formatting).
if let Some(part) = doc.parts.get(&cm.part) {
let BoxLayout { items: body, height, .. } = layout_box(ctx, story, &part.blocks, &[], inner, None, 1, None);
for mut it in body {
it.translate(x, y);
items.push(it);
}
y += height;
}
}
cards.push(Card { page: caret.page, ax: caret.x, ay: caret.top + caret.height, items, h: y + PAD, color });
}
// Every page gets the markup area, so the pages keep one size.
let widths: Vec<f32> = lay.pages.iter().map(|p| p.w).collect();
for p in &mut lay.pages {
p.w += MARKUP_AREA;
p.markup = MARKUP_AREA;
}
let mut by_page: BTreeMap<usize, Vec<Card>> = BTreeMap::new();
for c in cards {
by_page.entry(c.page).or_default().push(c);
}
for (pi, mut list) in by_page {
let (Some(page), Some(&pw)) = (lay.pages.get_mut(pi), widths.get(pi)) else { continue };
list.sort_by(|a, b| a.ay.total_cmp(&b.ay).then(a.ax.total_cmp(&b.ax)));
let (top_min, bottom) = (EDGE, (page.h - EDGE).max(EDGE + MIN_CARD));
// Each card as near its anchor as the ones above allow; then pushed up from the page
// foot; then down again below the top, clipping what still doesn't fit.
let mut tops: Vec<f32> = Vec::with_capacity(list.len());
let mut next = top_min;
for c in &list {
let t = (c.ay - 10.0).max(next);
tops.push(t);
next = t + c.h + GAP;
}
let mut limit = bottom;
for (t, c) in tops.iter_mut().zip(&list).rev() {
if *t + c.h > limit {
*t = limit - c.h;
}
limit = *t - GAP;
}
let mut next = top_min;
let x0 = pw + SIDE;
for (c, t) in list.into_iter().zip(tops) {
let top = t.max(next);
// No room left beside this page (dozens of comments on it): the rest are left out
// rather than drawn off the paper.
if top + MIN_CARD > bottom + 0.5 {
break;
}
let avail = bottom - top;
let (items, h) = if c.h > avail { (fit_box(c.items, avail - PAD), avail) } else { (c.items, c.h) };
next = top + h + GAP;
let line = |style, width: f32| Border { style, width, color: Some(c.color), space: 0.0 };
// Leader: along the text from the anchor to the page edge, then to the card.
page.items.push(Placed::Rule { x0: c.ax, y0: c.ay, x1: pw, y1: c.ay, border: line(BorderStyle::Dashed, 0.5) });
page.items.push(Placed::Rule { x0: pw, y0: c.ay, x1: x0, y1: top + 8.0, border: line(BorderStyle::Single, 0.5) });
// The card: white, outlined and striped in the author's colour.
let card = Rect::new(x0, top, card_w, h);
page.items.push(Placed::Fill { rect: card, color: Rgb(0xFF, 0xFF, 0xFF) });
page.items.push(Placed::Fill { rect: Rect::new(x0, top, STRIPE, h), color: c.color });
let (l, r, b) = (card.x, card.right(), card.bottom());
for (ax, ay, bx, by) in [(l, top, r, top), (r, top, r, b), (l, b, r, b), (l, top, l, b)] {
page.items.push(Placed::Rule { x0: ax, y0: ay, x1: bx, y1: by, border: line(BorderStyle::Single, 0.5) });
}
for mut it in items {
it.translate(x0, top);
page.items.push(it);
}
}
}
}

View File

@ -137,6 +137,25 @@ fn items_of(page: &Page, story: StoryRef) -> Box<dyn Iterator<Item = &Placed> +
}
}
/// Whether header/footer part `id` has text near (x, y) on the page (its header or footer
/// lines). Used so an object behind the header text is only grabbed where there's no text,
/// mirroring `text_at` for the body.
fn hf_text_at(p: &Page, id: u32, x: f32, y: f32) -> bool {
let story = StoryRef::Part(id);
p.header
.iter()
.chain(p.footer.iter())
.filter_map(|it| match it {
Placed::Lines { story: s, para, l0, l1, x: lx, y: ly, .. } if *s == story => Some((para, l0, l1, lx, ly)),
_ => None,
})
.any(|(para, l0, l1, lx, ly)| {
let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.checked_sub(1).unwrap_or(0))) else { return false };
let bottom = ly + last.top + last.height - first.top;
y >= *ly - 2.0 && y <= bottom + 2.0 && x >= *lx - 40.0 && x <= lx + last.right + 40.0
})
}
/// Every upright line on a page that belongs to `story` (body, or a header/footer part).
/// Turned lines (see [`turned_hit`]) are left out.
pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
@ -237,15 +256,32 @@ impl DocLayout {
/// border (within `edge` points either side; inside is its text). Topmost first; objects
/// behind the text only where there's no text.
pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
self.object_at_in(page, x, y, edge, StoryRef::Body)
}
/// The object of `story` a press at (x, y) grabs. Body looks only at the page's own items
/// (as before); a header/footer part looks only at that part's objects in the page's
/// header and footer, so a header picture placed over the body never steals body clicks.
pub fn object_at_in(&self, page: usize, x: f32, y: f32, edge: f32, story: StoryRef) -> Option<ObjectHit> {
let p = self.pages.get(page)?;
// A rotated object is grabbed on its rotated shape: the point is taken into its frame.
let grabs = |o: &ObjectHit| {
let pt = o.unspin(x, y);
o.rect.expand(edge).contains(pt) && (o.text_box.is_none() || !o.rect.expand(-edge).contains(pt) || o.rect.w.min(o.rect.h) <= edge * 3.0)
};
objects(p, page)
let cands: Vec<ObjectHit> = match story {
StoryRef::Body => objects(p, page).collect(),
StoryRef::Part(_) => all_objects(p, page).filter(|o| o.story == story).collect(),
};
let text_here = match story {
StoryRef::Body => text_at(p, page, x, y).is_some(),
StoryRef::Part(id) => hf_text_at(p, id, x, y),
};
cands
.iter()
.find(|o| !o.behind() && grabs(o))
.or_else(|| if text_at(p, page, x, y).is_some() { None } else { objects(p, page).find(|o| o.behind() && grabs(o)) })
.or_else(|| if text_here { None } else { cands.iter().find(|o| o.behind() && grabs(o)) })
.cloned()
}
/// The text box in the header or footer at (x, y) on `page`.
@ -619,7 +655,7 @@ impl DocLayout {
pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> {
let p = self.pages.get(page)?;
p.items.iter().rev().find_map(|it| match it {
Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => {
Placed::Cell { rect, table, row, cell, story, covered: false } if rect.contains(wordcraft_geom::Point::new(x, y)) => {
Some((*story, table.clone(), *row, *cell))
}
_ => None,

View File

@ -8,6 +8,7 @@
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod balloons;
mod contour;
pub mod display;
pub mod fields;
@ -61,6 +62,10 @@ pub struct LayoutOptions {
pub hide_deleted: bool,
/// Check spelling as you type (squiggles).
pub proofing: bool,
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
/// and show the comments there in balloons joined to their anchors, as Word prints them.
/// The editor draws its own interactive balloons and leaves this off.
pub comment_balloons: bool,
/// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9).
/// A field whose own `code` flag is set shows the other one.
pub field_codes: bool,
@ -152,9 +157,12 @@ pub enum Placed {
spin: Spin,
items: Vec<Placed>,
},
/// A table cell's area (for hit testing and cell selection).
/// A table cell's area (for hit testing and cell selection). A vertically merged cell is
/// one `Cell` as tall as all its rows; its continuation rows get a `covered` `Cell` (their
/// own row's height) so the rows keep a box each, but it isn't a cell of its own on screen.
Cell {
rect: Rect,
covered: bool,
table: Path,
row: usize,
cell: usize,
@ -314,6 +322,9 @@ pub struct Page {
pub footer_story: Option<u32>,
/// Top-level body block index at the start of the page.
pub first_block: usize,
/// Width of the markup area on the right of `w` that holds comment balloons (Print Markup,
/// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide.
pub markup: f32,
}
/// The whole layout.
@ -1783,7 +1794,12 @@ fn layout_pass(
}
}
}
Pass::Done(DocLayout { pages, index, ms: 0.0, text_boxes: ctx.boxes.total().saturating_sub(boxes_discarded) })
let mut lay = DocLayout { pages, index, ms: 0.0, text_boxes: 0 };
if opts.comment_balloons && !web {
balloons::add(&mut ctx, &mut lay);
}
lay.text_boxes = ctx.boxes.total().saturating_sub(boxes_discarded);
Pass::Done(lay)
}
/// Pagination state saved before a block, to lay the blocks from there out again (balancing
@ -2991,6 +3007,8 @@ mod tests;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_complex;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_pages;

View File

@ -653,7 +653,9 @@ impl<'a> Builder<'a> {
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
let bounds: Vec<usize> = unicode_segmentation::UnicodeSegmentation::grapheme_indices(sub, true).map(|(i, _)| i).collect();
// Complex-script syllables the graphemes split (Myanmar vowel signs, stacked
// consonants) stay one cluster: the caret never stops inside them.
let bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(sub);
let mut gi = 0usize;
for (bi, &gs) in bounds.iter().enumerate() {
let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len());
@ -1220,6 +1222,12 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
found.push(i);
}
}
// Myanmar has no spaces between words: it wraps between syllables.
if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) {
found.extend(wordcraft_doc::cluster::myanmar_breaks(&text));
found.sort_unstable();
found.dedup();
}
// Asian typography: kinsoku and breaking Latin words anywhere.
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();

View File

@ -662,7 +662,14 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
if let Some(e) = b.right.filter(Border::is_visible) {
items.push(Placed::Rule { x0: x1, y0, x1, y1, border: e });
}
items.push(Placed::Cell { rect: Rect::new(c.x, 0.0, c.w, rh), table: wordcraft_doc::Path(path.to_vec()), row: ri, cell: ci, story });
items.push(Placed::Cell {
rect: Rect::new(c.x, 0.0, c.w, if c.vmerge == VMerge::Continue { rh } else { span_h }),
covered: c.vmerge == VMerge::Continue,
table: wordcraft_doc::Path(path.to_vec()),
row: ri,
cell: ci,
story,
});
}
out.push(RowLayout { height: rh, items });
}
@ -790,9 +797,9 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
a.extend(above.map(|rect| Placed::Fill { rect, color: *color }));
b.extend(below.map(|rect| Placed::Fill { rect, color: *color }));
}
Placed::Cell { rect, table, row: r, cell, story } => {
Placed::Cell { rect, covered, table, row: r, cell, story } => {
let (above, below) = split_rect(*rect, cut, shift);
let cell_at = |rect| Placed::Cell { rect, table: table.clone(), row: *r, cell: *cell, story: *story };
let cell_at = |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story };
a.extend(above.map(cell_at));
b.extend(below.map(cell_at));
}

View File

@ -498,6 +498,29 @@ fn tables_lay_out_cells() {
assert!(l.cell_at(0, c.x, c.top + 2.0).is_some());
}
/// A vertical merge is one cell: its box spans the rows, a click anywhere in it finds it, and the
/// continuation row's box is `covered` (no gridline, not a hit).
#[test]
fn a_vertical_merge_lays_out_as_one_cell() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(3, 2, 468.0);
t.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
t.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let cells: Vec<_> = l.pages[0]
.items
.iter()
.filter_map(|i| if let Placed::Cell { rect, row, cell: 0, covered, .. } = i { Some((*row, *rect, *covered)) } else { None })
.collect();
let (r0, r1) = (cells[0].1, cells[1].1);
assert!(!cells[0].2 && cells[1].2 && !cells[2].2, "{cells:?}");
assert!((r0.bottom() - r1.bottom()).abs() < 0.01 && r0.h > r1.h * 1.9, "{r0:?} {r1:?}");
// The lower half of the merge hits the restart cell (row 0), not the continuation row.
let hit = l.cell_at(0, r1.x + 5.0, r1.y + r1.h / 2.0).unwrap();
assert_eq!((hit.2, hit.3), (0, 0));
}
/// A 1×3 table after "before" whose middle cell holds `text` running `dir`.
fn turned_table(dir: TextDirection, text: &str, row: Option<f32>) -> Document {
let mut d = Document::from_text("before\nafter");
@ -3149,6 +3172,104 @@ fn footnote_longer_than_pages_ends() {
assert!(pieces.windows(2).all(|w| w[0].2 == w[1].1 && w[1].0 == w[0].0 + 1), "{pieces:?}");
}
/// A comment by `author` saying `text` on body paragraph `para`, bytes `a..b`; its id.
fn comment_on(d: &mut Document, para: usize, a: usize, b: usize, author: &str, text: &str, parent: Option<u32>) -> u32 {
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text(text, CharProps::default()))]);
let id = d.comments.keys().next_back().map_or(0, |k| k + 1);
d.comments.insert(id, wordcraft_doc::Comment { author: author.into(), part, parent, ..Default::default() });
if parent.is_none() {
let p = CharProps::default();
d.insert_object(&Pos::body(para, b), InlineObject::CommentEnd { id }, &p).unwrap();
d.insert_object(&Pos::body(para, a), InlineObject::CommentStart { id }, &p).unwrap();
}
id
}
fn balloon_layout(d: &Document) -> DocLayout {
layout(d, &mut LayoutCache::new(), &LayoutOptions { comment_balloons: true, ..Default::default() })
}
/// The comment text lines placed on page `pi` (story, top-left).
fn comment_lines(l: &DocLayout, pi: usize) -> Vec<(StoryRef, f32, f32)> {
l.pages[pi]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: s @ StoryRef::Part(_), x, y, .. } => Some((*s, *x, *y)),
_ => None,
})
.collect()
}
/// Print Markup (#589): each page grows by the markup area, and the comment, its author and its
/// reply are laid out there beside the line they comment on.
#[test]
fn comment_balloons_widen_pages_beside_their_anchors() {
let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line.");
let id = comment_on(&mut d, 1, 4, 13, "Ada Reviewer", "Check the totals", None);
comment_on(&mut d, 0, 0, 0, "Ben Editor", "Totals checked", Some(id));
let plain = lay(&d);
let l = balloon_layout(&d);
assert_eq!(l.pages.len(), plain.pages.len());
let (w, pw) = (l.pages[0].w, plain.pages[0].w);
assert_eq!(l.pages[0].markup, balloons::MARKUP_AREA);
assert!((w - pw - balloons::MARKUP_AREA).abs() < 0.01, "{w} vs {pw}");
assert_eq!(plain.pages[0].markup, 0.0);
// Author + text for the comment, then for the reply: four lines, all in the markup area.
let lines = comment_lines(&l, 0);
assert_eq!(lines.len(), 4, "{lines:?}");
assert!(lines.iter().all(|(_, x, _)| *x > pw && *x < w), "{lines:?}");
assert!(lines.windows(2).all(|p| p[0].2 < p[1].2), "stacked top to bottom: {lines:?}");
// The card sits by the commented line.
let anchor = l.caret(&Pos::body(1, 5)).unwrap();
assert!((lines[0].2 - anchor.top).abs() < 30.0, "{lines:?} vs {anchor:?}");
// The body is laid out exactly as without balloons.
assert_eq!(lines_of(&plain), lines_of(&l) - 4);
// The leader starts at the anchor.
assert!(l.pages[0].items.iter().any(|it| matches!(it, Placed::Rule { x0, x1, .. } if (*x0 - anchor.x).abs() < 0.5 && (*x1 - pw).abs() < 0.5)));
}
#[test]
fn no_balloons_without_anchored_comments() {
let d = Document::from_text("No comments here.");
let l = balloon_layout(&d);
assert_eq!(l.pages[0].markup, 0.0);
assert_eq!(l.pages[0].w, lay(&d).pages[0].w);
// A web view never gets a markup area.
let mut d = Document::from_text("Hello there.");
comment_on(&mut d, 0, 0, 5, "A", "x", None);
let opts = LayoutOptions { view: ViewMode::Web, web_width: 500.0, comment_balloons: true, ..Default::default() };
let l = layout(&d, &mut LayoutCache::new(), &opts);
assert_eq!(l.pages[0].markup, 0.0);
}
/// Hostile comments: parent cycles, a missing text part, and far more comments than fit beside
/// one page. Nothing panics or hangs, and the cards stay on the page.
#[test]
fn comment_balloons_survive_hostile_comments() {
let mut d = Document::from_text(&"Commented words here. ".repeat(20));
let mut ids = Vec::new();
for i in 0..120 {
ids.push(comment_on(&mut d, 0, 0, 9, &format!("Author {i}"), &"A long remark that wraps over lines. ".repeat(4), None));
}
// A reply cycle and a comment whose text part is gone.
let a = comment_on(&mut d, 0, 0, 0, "R", "one", Some(ids[0]));
let b = comment_on(&mut d, 0, 0, 0, "R", "two", Some(a));
d.comments.get_mut(&a).unwrap().parent = Some(b);
d.comments.get_mut(&ids[1]).unwrap().parent = Some(ids[1]);
d.comments.get_mut(&ids[2]).unwrap().part = 9_999_999;
let l = balloon_layout(&d);
let page = &l.pages[0];
for (_, _, y) in comment_lines(&l, 0) {
assert!(y.is_finite() && y >= 0.0 && y <= page.h, "{y}");
}
for it in &page.items {
if let Placed::Fill { rect, .. } = it {
assert!(rect.y >= 0.0 && rect.bottom() <= page.h + 0.5, "{rect:?}");
}
}
}
/// The first paragraph's lines on page one: (top, bottom, left, right), relative to its column.
fn line_spans(l: &DocLayout) -> Vec<(f32, f32, f32, f32)> {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { return Vec::new() };

View File

@ -0,0 +1,111 @@
//! Complex scripts that reorder and stack (#604): Myanmar, Khmer, Devanagari. Cluster and line
//! breaking tests don't depend on installed fonts (a missing font draws .notdef boxes, but the
//! clusters and breaks are the same); shaping tests use a system font when one is installed.
use super::*;
use wordcraft_doc::{Pos, StoryRef};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
/// Body paragraph `i`'s layout.
fn para_layout(l: &DocLayout, i: u32) -> &ParaLayout {
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Body, path, para, .. } = it
&& path.0 == vec![i]
{
return para;
}
}
}
panic!("paragraph {i} not laid out");
}
/// "မြန်မာစာ": the syllables မြ | န် | မာ | စာ.
const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\u{102C}";
#[test]
fn myanmar_syllables_are_single_clusters() {
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
// Grapheme clusters would also start at the aa signs (bytes 15 and 21), splitting မာ and စာ.
assert_eq!(starts, vec![0, 6, 12, 18]);
// The caret can't land inside a syllable: every caret offset is a cluster start or the end.
let offs = pl.line_offsets(0);
assert!(!offs.contains(&15) && !offs.contains(&21), "{offs:?}");
// Stacked consonants (virama) and Khmer subscripts (coeng) too.
let l = lay(&Document::from_text("\u{1019}\u{1039}\u{1018}\u{102C} \u{1781}\u{17D2}\u{1798}\u{17C2}\u{179A}"));
let pl = para_layout(&l, 0);
let starts: Vec<usize> = pl.clusters.iter().map(|c| c.start).collect();
assert_eq!(starts, vec![0, 12, 13, 25]);
}
#[test]
fn myanmar_without_spaces_wraps_between_syllables() {
let text = MYANMAR.repeat(80);
let l = lay(&Document::from_text(&text));
let pl = para_layout(&l, 0);
assert!(pl.lines.len() > 1, "a long Myanmar paragraph wraps");
let breaks = wordcraft_doc::cluster::myanmar_breaks(&text);
// A line may end before each syllable: before မာ and စာ, never before န် (a final).
for c in &pl.clusters {
assert_eq!(c.break_after, breaks.contains(&c.end), "break after {}", c.end);
}
assert!(pl.clusters.iter().any(|c| c.break_after && c.end == 12) && !pl.clusters.iter().any(|c| c.break_after && c.end == 6));
for line in pl.lines.iter().skip(1) {
let start = pl.clusters[line.c0].start;
assert!(breaks.contains(&start), "line starts at syllable {start}");
}
// Lines are full: a syllable break, not an emergency break after one cluster.
assert!(pl.lines[0].c1 - pl.lines[0].c0 > 10);
}
/// A face with Myanmar letters (an installed system font), or `None` to skip.
fn myanmar_face() -> Option<wordcraft_fonts::FaceRef> {
wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables())
}
#[test]
fn myanmar_prevowel_is_drawn_before_its_consonant() {
let Some(face) = myanmar_face() else {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
};
// ကေ: in memory consonant first, on screen the vowel sign ေ first.
let ka = face.glyph_for('\u{1000}');
let g = wordcraft_fonts::shape(&face, "\u{1000}\u{1031}", &[], |c| c);
assert_eq!(g.len(), 2, "{g:?}");
assert_eq!(g[1].gid, ka, "the consonant is drawn second: {g:?}");
assert!(g.iter().all(|x| x.cluster == 0), "one cluster: {g:?}");
// Itemised by script: the same syllable after Latin text in the same face shapes the same
// (shaped as one Latin run, the vowel sign would stay after the consonant).
if face.covers('H') {
let mixed = wordcraft_fonts::shape(&face, "Hi \u{1000}\u{1031}", &[], |c| c);
let tail: Vec<u32> = mixed.iter().filter(|x| x.cluster >= 3).map(|x| x.gid).collect();
assert_eq!(tail, g.iter().map(|x| x.gid).collect::<Vec<_>>());
}
}
#[test]
fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
if myanmar_face().is_none() {
eprintln!("skipped: no Myanmar font with OpenType tables installed");
return;
}
let l = lay(&Document::from_text(MYANMAR));
let pl = para_layout(&l, 0);
let faces: Vec<_> = pl.clusters.iter().map(|c| pl.styles[c.style as usize].face).collect();
assert!(faces.windows(2).all(|w| w[0].id() == w[1].id()), "one face for the word");
let f = faces[0];
assert!(f.has_layout_tables(), "{} can reorder Myanmar", f.family);
assert!(!f.italic && (f.weight - 400.0).abs() <= 100.0, "a regular weight, not {} {}", f.family, f.style);
assert!(pl.glyphs.iter().all(|g| g.gid != 0), "no missing glyphs");
// The caret after the first syllable sits after its glyphs.
let x6 = l.caret(&Pos::body(0, 6)).map(|c| c.x).unwrap_or(0.0);
let x0 = l.caret(&Pos::body(0, 0)).map(|c| c.x).unwrap_or(0.0);
assert!(x6 > x0);
}

View File

@ -67,3 +67,43 @@ fn resources() {
let r = call(&mut s, 2, "resources/read", json!({"uri": "wordcraft://document"}));
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("blocks"));
}
/// A backend standing in for a running app: it answers `ui.render` and `ui.screenshot` with a
/// PNG in the reply and records what it was asked.
struct FakeApp {
calls: std::sync::Arc<std::sync::Mutex<Vec<(String, Value)>>>,
}
impl crate::backend::Backend for FakeApp {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.calls.lock().unwrap().push((method.to_string(), params.clone()));
match method {
"ui.render" | "ui.screenshot" => Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(b"\x89PNG"), "width": 1, "height": 1})),
other => Err(format!("unexpected {other}")),
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"fake app".into()
}
}
/// With an app attached, pictures travel inside the reply: the bridge never writes a file with a
/// predictable name into the shared temporary folder (another user could plant a link there).
#[test]
fn render_and_screenshot_through_the_app_use_no_temp_file() {
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut s = Server::new(Box::new(FakeApp { calls: calls.clone() }));
for (tool, method) in [("render_page", "ui.render"), ("screenshot", "ui.screenshot")] {
let r = call(&mut s, 10, "tools/call", json!({"name": tool, "arguments": {"page": 1}}));
let content = &r["result"]["content"];
assert_eq!(r["result"]["isError"], false, "{tool}: {r}");
assert_eq!(content[0]["type"], "image", "{tool}");
assert_eq!(content[0]["data"], wordcraft_engine::cmd::insert::base64_encode(b"\x89PNG"), "{tool}");
let (m, params) = calls.lock().unwrap().pop().expect("one call");
assert_eq!(m, method);
assert!(params.get("path").is_none(), "{tool} asked for a file: {params}");
}
}

View File

@ -4,6 +4,14 @@ use serde_json::{Value, json};
use crate::backend::Backend;
/// An image tool result from a reply carrying base64 PNG in `png`.
fn png_result(v: &Value, info: &Value) -> ToolResult {
match v.get("png").and_then(Value::as_str) {
Some(png) if !png.is_empty() => ToolResult::image(png.to_string(), info),
_ => ToolResult::error("the reply carried no picture"),
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ToolResult {
pub content: Vec<Value>,
@ -80,7 +88,7 @@ pub fn tool_definitions() -> Value {
tool(
"open_document",
"Open",
"Open a .docx, .odt, .rtf, .md, .tex, .html, .txt or .json file.",
"Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text.",
obj(json!({"path": s("File path")}), &["path"]),
false
),
@ -244,37 +252,19 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
"get_text" => wrap(exec(b, "document.text", json!({}))),
"inspect_document" => wrap(b.call("document.inspect", json!({}))),
"parity" => wrap(b.call("ui.parity", json!({}))),
// Pictures travel inside the reply (`png`), headless or through the app: no file with a
// predictable name in the shared temporary folder.
"render_page" => {
if b.has_ui() {
let path = std::env::temp_dir().join("wordcraft-mcp-page.png");
let path_s = path.to_string_lossy().to_string();
match b.call("ui.render", json!({"path": path_s, "page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
Err(e) => ToolResult::error(e),
}
} else {
match b.call("ui.render", a.clone()) {
Ok(v) => {
let png = v.get("png").and_then(Value::as_str).unwrap_or("").to_string();
ToolResult::image(png, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]}))
}
Err(e) => ToolResult::error(e),
}
}
}
"screenshot" => {
let path = std::env::temp_dir().join("wordcraft-mcp-shot.png");
match b.call("ui.screenshot", json!({"path": path.to_string_lossy()})) {
Ok(info) => match std::fs::read(&path) {
Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info),
Err(e) => ToolResult::error(e.to_string()),
},
let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))});
match b.call("ui.render", params) {
Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})),
Err(e) => ToolResult::error(e),
}
}
"screenshot" => match b.call("ui.screenshot", json!({})) {
Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"]})),
Err(e) => ToolResult::error(e),
},
"click" => wrap(b.call("ui.click", a.clone())),
"key" => wrap(b.call("ui.key", a.clone())),
"ui_inspect" => wrap(b.call("ui.inspect", json!({}))),

View File

@ -78,20 +78,25 @@ pub struct PdfOptions {
pub tagged: bool,
/// Show tracked changes as markup (coloured, underlined / struck through).
pub include_markup: bool,
/// Print Markup's comments: each page is scaled down to make room on its right for the
/// comments in balloons joined to their anchors (Word's "Document showing markup").
pub comments: bool,
/// Compress content streams.
pub compress: bool,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, compress: true }
PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, comments: false, compress: true }
}
}
/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked
/// deletions are left out, as in Word's "No Markup" view.
/// deletions are left out, as in Word's "No Markup" view. With `comments` each page carries the
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let lay = layout(doc, &mut LayoutCache::new(), &LayoutOptions { hide_deleted: !opts.include_markup, ..Default::default() });
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout(doc, &lay, opts)
}
@ -155,16 +160,26 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
}
for (out, &pi) in pages.iter().enumerate() {
for &pi in pages {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi + 1)) };
let (w, h) = (clamp_side(page.w), clamp_side(page.h));
let fit = Fit::of(page);
let (w, h) = (clamp_side(fit.paper_w), clamp_side(page.h));
let size = Size::from_wh(w, h).ok_or(PdfError::NoPages)?;
let mut kp = pdf.start_page_with(PageSettings::new(size));
let mut s = kp.surface();
ex.links.clear();
// A page with a markup area is scaled down to fit its paper with the area beside it.
let scaled = fit.k < 1.0;
if scaled {
s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, 0.0, fit.dy));
}
// `w` is the document page's width: the page colour and watermark stay off the markup area.
ex.page(&mut s, page, w, h);
if scaled {
s.pop();
}
s.finish();
for a in ex.annotations(out) {
for a in ex.annotations(fit) {
kp.add_annotation(a);
}
kp.finish();
@ -185,6 +200,32 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
}
}
/// How a layout page maps onto its PDF page: a page with a markup area (Print Markup) is scaled
/// by `k` to its paper width and centred vertically (`dy`); any other page is drawn as it is.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fit {
paper_w: f32,
k: f32,
dy: f32,
}
impl Fit {
fn of(page: &Page) -> Fit {
let paper_w = page.w - page.markup;
if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 {
let k = paper_w / page.w;
Fit { paper_w, k, dy: page.h * (1.0 - k) / 2.0 }
} else {
Fit { paper_w: page.w, k: 1.0, dy: 0.0 }
}
}
/// A layout point on the PDF page.
fn point(self, x: f32, y: f32) -> (f32, f32) {
(x * self.k, y * self.k + self.dy)
}
}
/// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates.
fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option<Path> {
let db = wordcraft_fonts::FontDb::global();
@ -409,9 +450,10 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
ShapeKind::Ellipse => ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0),
ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]),
ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]),
// DrawingML's `line`: top-left to bottom-right (flips give the other directions).
ShapeKind::Line => {
pb.move_to(x0, y1);
pb.line_to(x1, y0);
pb.move_to(x0, y0);
pb.line_to(x1, y1);
}
// Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal.
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {
@ -1195,8 +1237,9 @@ impl Exporter<'_> {
let places = self.lay.index.get(&(story, path.clone()))?;
for (pi, ii) in places {
let Some(out) = self.out_index.get(pi) else { continue };
let (x, y) = match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) {
Some(Placed::Lines { x, y, .. }) => (*x, *y),
let Some(page) = self.lay.pages.get(*pi) else { continue };
let (x, y) = match page.items.get(*ii) {
Some(Placed::Lines { x, y, .. }) => Fit::of(page).point(*x, *y),
_ => (0.0, 0.0),
};
return Some((*out, x, y));
@ -1204,10 +1247,11 @@ impl Exporter<'_> {
None
}
fn annotations(&self, _out: usize) -> Vec<Annotation> {
fn annotations(&self, fit: Fit) -> Vec<Annotation> {
let mut v = Vec::new();
for (x0, y0, x1, y1, target) in &self.links {
let Some(rect) = krilla::geom::Rect::from_ltrb(*x0, *y0, *x1, *y1) else { continue };
let ((x0, y0), (x1, y1)) = (fit.point(*x0, *y0), fit.point(*x1, *y1));
let Some(rect) = krilla::geom::Rect::from_ltrb(x0, y0, x1, y1) else { continue };
let t = if let Some(name) = target.strip_prefix('#') {
match self.bookmark(name) {
Some((page, x, y)) => Target::Destination(XyzDestination::new(page, Point::from_xy(x, y)).into()),
@ -1238,7 +1282,8 @@ impl Exporter<'_> {
if title.is_empty() {
continue;
}
flat.push((level.min(8), title, out, *x, *y));
let (x, y) = Fit::of(page).point(*x, *y);
flat.push((level.min(8), title, out, x, y));
}
}
if flat.is_empty() {

View File

@ -798,3 +798,40 @@ fn bookmark_destination_is_the_page_of_its_start() {
assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}");
assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0));
}
/// Print Markup (#589): the comments and their replies print beside each page, which is scaled
/// down to keep its paper size; without it they stay out of the PDF.
#[test]
fn comments_print_in_balloons_beside_scaled_pages() {
let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line.");
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("Check the totals", CharProps::default()))]);
d.comments.insert(0, wordcraft_doc::Comment { author: "Ada Reviewer".into(), part, ..Default::default() });
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("Totals checked", CharProps::default()))]);
d.comments.insert(1, wordcraft_doc::Comment { author: "Ben Editor".into(), part, parent: Some(0), ..Default::default() });
d.insert_object(&wordcraft_doc::Pos::body(1, 13), InlineObject::CommentEnd { id: 0 }, &CharProps::default()).unwrap();
d.insert_object(&wordcraft_doc::Pos::body(1, 4), InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
let size = |bytes: &[u8]| {
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
pdf.pages().iter().map(|p| p.media_box()).map(|r| (r.width().round(), r.height().round())).collect::<Vec<_>>()
};
let plain = export(&d, &PdfOptions::default()).unwrap();
let text = squash(&extract_text(&plain).concat());
assert!(text.contains("quarterly figures") && !text.contains("Check the totals") && !text.contains("Ada Reviewer"), "{text}");
let marked = export(&d, &PdfOptions { include_markup: true, comments: true, ..Default::default() }).unwrap();
let text = squash(&extract_text(&marked).concat());
for want in ["quarterly figures", "Ada Reviewer", "Check the totals", "Ben Editor", "Totals checked"] {
assert!(text.contains(want), "{want} missing: {text}");
}
// Same paper: the page and its markup area are scaled to fit it.
assert_eq!(size(&plain), size(&marked));
assert_eq!(size(&marked), vec![(612.0, 792.0)]);
// Tagged or not, with links and an outline, it still writes.
let mut d2 = sample();
let part = d2.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("On the heading", CharProps::default()))]);
d2.comments.insert(0, wordcraft_doc::Comment { author: "Ada".into(), part, ..Default::default() });
d2.insert_object(&wordcraft_doc::Pos::body(1, 3), InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap();
for tagged in [false, true] {
let bytes = export(&d2, &PdfOptions { tagged, comments: true, ..Default::default() }).unwrap();
assert!(squash(&extract_text(&bytes).concat()).contains("On the heading"));
}
}

View File

@ -421,11 +421,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
if raw_w <= 0.0 {
return;
}
let diag = if wm.diagonal { ((page.w as f64).powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { page.w as f64 };
// The document page, not a Print Markup area beside it.
let pw = (page.w - page.markup.max(0.0)).max(1.0) as f64;
let diag = if wm.diagonal { (pw.powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { pw };
let size = (diag * 0.5 / raw_w).min(200.0);
let k = size / upem;
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / page.w.max(1.0) as f64).atan() } else { 0.0 };
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0))
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / pw).atan() } else { 0.0 };
let base = Affine::translate((pw / 2.0, page.h.min(2000.0) as f64 / 2.0))
* Affine::rotate(angle)
* Affine::translate((-raw_w * size / 2.0, size * 0.35));
ctx.set_paint(color(opts.ink(wm.color), if wm.semitransparent { 0.35 } else { 0.8 }));
@ -824,10 +826,11 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1),
ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]),
ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]),
// DrawingML's `line`: top-left to bottom-right (flips give the other directions).
ShapeKind::Line => {
let mut p = BezPath::new();
p.move_to((r.x0, r.y1));
p.line_to((r.x1, r.y0));
p.move_to((r.x0, r.y0));
p.line_to((r.x1, r.y1));
p
}
// Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal.
@ -1202,6 +1205,14 @@ mod tests {
assert!(!shape_path(k, r).elements().is_empty());
}
}
/// #611: a line runs as DrawingML's `line` preset does, top-left to bottom-right (Word's
/// files flip it for the other diagonal), not bottom-left to top-right.
#[test]
fn a_line_runs_from_top_left_to_bottom_right() {
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0));
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
}
}
#[cfg(test)]

View File

@ -331,49 +331,27 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
}
fn export_page(app: &mut WordApp, ui: &mut Ui) {
let is_print = app.ui.backstage_page == "print";
heading(ui, if is_print { "Print" } else { "Export" });
if is_print && (app.services.print.is_some() || app.services.print_native.is_some()) {
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
} else {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
}
ui.add_space(12.0);
if app.ui.backstage_page == "print" {
heading(ui, "Print");
// Print Markup (a print setting, as in Word): the comments beside each page.
let mut markup = app.session.prefs.print_markup;
if ui
.add_enabled(
app.services.print_native.is_none() || app.printing.ready(),
egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)),
)
.clicked()
.checkbox(&mut markup, tl!("Print Markup"))
.on_hover_text(tl!(
"Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text."
))
.changed()
{
let _ = app.run("ui.print", json!({}));
let _ = app.run("file.printMarkup", json!({"value": markup}));
}
ui.add_space(12.0);
ui.separator();
ui.add_space(12.0);
ui.label(tl!("Or save a copy in another format:"));
} else {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
crate::print_page::show(app, ui);
return;
}
heading(ui, "Export");
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
ui.add_space(12.0);
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
("Word document (*.docx)", "docx"),
("Word XML Document (*.xml)", "xml"),
("OpenDocument Text (*.odt)", "odt"),
("Rich Text Format (*.rtf)", "rtf"),
("Web page (*.html)", "html"),
("Markdown (*.md)", "md"),
("LaTeX (*.tex)", "tex"),
("Plain text (*.txt)", "txt"),
("Page image (*.png)", "png"),
] {
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
app.export_dialog(ext);
}
ui.add_space(4.0);
}
crate::print_page::export_formats(app, ui);
}
#[cfg(test)]

View File

@ -25,6 +25,12 @@ pub struct CanvasState {
dragging: bool,
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
/// The last primary press on the page (time, screen point, clicks in a row), to count
/// double- and triple-presses as they go down (egui reports them on release).
last_press: Option<(f64, Pos2, u8)>,
/// A drag after a double-click, triple-click or Ctrl/⌘+click: the unit it extends by, the
/// unit first selected, and the last position the pointer hit.
unit_drag: Option<UnitDrag>,
pub last_highlight: String,
pub last_font_color: String,
pub last_shading: String,
@ -37,6 +43,8 @@ pub struct CanvasState {
pub focused: bool,
pub render_ms: f64,
pub ime_preedit: String,
/// Document faces added to the interface fonts to draw the input method's composition (#565).
pub(crate) ime_faces: Vec<std::sync::Arc<wordcraft_fonts::FontFace>>,
pub want_focus: bool,
pub context_issue: Option<serde_json::Value>,
pub context_synonyms: Option<serde_json::Value>,
@ -99,6 +107,8 @@ impl Default for CanvasState {
caret_visible_since: 0.0,
dragging: false,
column_drag: None,
last_press: None,
unit_drag: None,
last_highlight: "yellow".into(),
last_font_color: "C00000".into(),
last_shading: "FFF2CC".into(),
@ -109,6 +119,7 @@ impl Default for CanvasState {
focused: false,
render_ms: 0.0,
ime_preedit: String::new(),
ime_faces: Vec::new(),
want_focus: true,
context_issue: None,
context_synonyms: None,
@ -137,6 +148,25 @@ impl Default for CanvasState {
}
}
/// See [`CanvasState::unit_drag`].
struct UnitDrag {
unit: &'static str,
/// Clicks in a row that made it (2 or 3; 1 for Ctrl/⌘+click).
clicks: u8,
start: Pos,
end: Pos,
last: Pos,
}
/// Clicks in a row for a press at `at` (time `now`) after `last`: within the double-click
/// delay and distance it counts up to 3 (a triple-click), otherwise it starts again at 1.
fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 {
match last {
Some((t, p, n)) if now - t >= 0.0 && now - t < delay && p.distance(at) <= dist => n.saturating_add(1).min(3),
_ => 1,
}
}
impl CanvasState {
/// True while a mouse drag-select is in progress (the mini toolbar waits for mouse-up).
pub fn drag_selecting(&self) -> bool {
@ -151,6 +181,62 @@ pub struct Geometry {
pub scale: f32,
/// Pages per row.
pub cols: usize,
/// Per page, the vertical range (pixels from the page rect's top) that is on screen. The
/// whole page, except with Hide White Space, which cuts the margins between pages.
pub bands: Vec<(f32, f32)>,
}
/// Room kept above the first line and below the last line of a page when white space is hidden (points).
const WHITE_SPACE_PAD: f32 = 6.0;
/// The line drawn between two pages when white space is hidden (pixels).
const SEAM: f32 = 1.0;
/// Hide White Space: stack single-column pages (`rects`, full pages) so that only each page's
/// text area (`bodies`: top and bottom of the body in points, plus a little room) shows, with a
/// [`SEAM`] between pages. The first page keeps its top and the last its bottom. Rects keep the
/// full page size, so page coordinates still map as `(p - rect.min) / scale`; neighbouring rects
/// overlap in the hidden margins. Returns the moved rects, the visible band of each, and the
/// content height.
fn collapse_white_space(rects: &[Rect], bodies: &[(f32, f32)], scale: f32, avail_h: f32) -> (Vec<Rect>, Vec<(f32, f32)>, f32) {
let n = rects.len();
let scale = if scale.is_finite() { scale.clamp(0.01, 10.0) } else { PX_PER_PT };
let avail_h = if avail_h.is_finite() { avail_h } else { 0.0 };
let mut out = Vec::with_capacity(n);
let mut bands = Vec::with_capacity(n);
let Some(first) = rects.first() else { return (out, bands, avail_h) };
let mut cursor = first.min.y;
for (i, r) in rects.iter().enumerate() {
let h = if r.height().is_finite() { r.height().max(0.0) } else { 0.0 };
let (bt, bb) = bodies.get(i).copied().unwrap_or((0.0, h));
let fin = |v: f32, d: f32| if v.is_finite() { v } else { d };
let top = if i == 0 { 0.0 } else { (fin(bt, 0.0) * scale - WHITE_SPACE_PAD * scale).clamp(0.0, h) };
let bot = if i + 1 == n { h } else { (fin(bb, 0.0) * scale + WHITE_SPACE_PAD * scale).clamp(top, h) };
let y = cursor - top;
out.push(Rect::from_min_size(pos2(r.min.x, y), r.size()));
bands.push((top, bot));
cursor = y + bot + SEAM;
}
(out, bands, (cursor - SEAM + GAP).max(avail_h))
}
/// True when any page is cut to a band (white space is hidden).
fn white_space_hidden(rects: &[Rect], bands: &[(f32, f32)]) -> bool {
rects.iter().zip(bands).any(|(r, b)| b.0 > 0.5 || b.1 < r.height() - 0.5)
}
/// True when a double-click at `p` is on the space between two pages (or on the seam when white
/// space is hidden): Word's way to toggle Hide White Space.
fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool {
let hidden = white_space_hidden(rects, bands);
rects.windows(2).enumerate().any(|(i, w)| {
let (a, b) = (w[0], w[1]);
let in_x = p.x >= a.min.x.min(b.min.x) && p.x <= a.max.x.max(b.max.x);
let (y0, y1) = match bands.get(i) {
Some(&(_, bot)) if hidden => (a.min.y + bot - 3.0, a.min.y + bot + SEAM + 3.0),
_ => (a.max.y, b.min.y),
};
in_x && p.y >= y0 && p.y <= y1
})
}
/// Width of the markup area beside each page for comment balloons (points), or 0.
@ -251,8 +337,16 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
};
let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) };
let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect();
let (rects, size) = place_pages(&sizes, markup, scale, cols, avail);
Geometry { rects, size, scale, cols }
let (mut rects, mut size) = place_pages(&sizes, markup, scale, cols, avail);
let mut bands: Vec<(f32, f32)> = rects.iter().map(|r| (0.0, r.height())).collect();
let editing_part = matches!(app.session.sel.focus.story, StoryRef::Part(_));
if v.hide_white_space && v.mode == wordcraft_layout::ViewMode::Print && !v.read_mode && cols == 1 && !editing_part && rects.len() > 1 {
let bodies: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.body.y, p.body.bottom())).collect();
let (r, b, h) = collapse_white_space(&rects, &bodies, scale, avail.y);
(rects, bands) = (r, b);
size.y = h;
}
Geometry { rects, size, scale, cols, bands }
}
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
@ -446,6 +540,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
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);
let key = page_key(app, page, scale_px, dim_body);
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
@ -456,9 +551,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
sr
};
rects.push(visual_sr);
if !sr.intersects(ui.clip_rect().expand(200.0)) {
// Hide White Space shows only a band of the page; everything below paints into it.
let band =
Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot));
if !band.intersects(ui.clip_rect().expand(200.0)) {
continue;
}
let painter = if band_top <= 0.0 && band_bot >= sr.height() {
painter.clone()
} else {
painter.with_clip_rect(painter.clip_rect().intersect(band))
};
// Shadow and paper.
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
@ -541,7 +644,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// Table Layout › View Gridlines: table cell outlines (on screen only).
if app.session.view.table_gridlines {
for it in &page.items {
if let Placed::Cell { rect, .. } = it {
if let Placed::Cell { rect, covered: false, .. } = it {
let r = Rect::from_min_size(
pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale),
vec2(rect.w * page_scale, rect.h * page_scale),
@ -551,6 +654,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
}
// Hide White Space: a thin line where one page ends and the next begins.
for (i, pair) in rects.windows(2).enumerate() {
let Some(&(_, bot)) = geo.bands.get(i) else { continue };
if white_space_hidden(&geo.rects, &geo.bands) {
let y = pair[0].min.y + bot;
painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong);
}
}
if rendered > 0 {
app.canvas.render_ms = crate::now_ms() - t0;
}
@ -676,6 +787,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
});
}
if !app.canvas.ime_preedit.is_empty() {
// The composition is interface text, but it may be in a script the interface fonts lack
// (Bangla, Thai ...): borrow the document fonts' fallback faces, or it draws as boxes (#565).
let new = crate::theme::faces_for_preedit(
&app.canvas.ime_preedit,
&app.canvas.ime_faces,
|c| (c as u32) < 0x250,
|c| wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX),
);
if !new.is_empty() {
app.canvas.ime_faces.extend(new);
crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces);
ui.ctx().request_repaint();
}
let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color);
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color));
}
@ -706,6 +830,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.focused = resp.has_focus();
// Screen readers: the document under the canvas, with the caret (#488).
crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area);
// Double-click the space between two pages (or the line between them) to hide or show it.
if resp.double_clicked()
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
&& !app.session.view.read_mode
&& let Some(p) = resp.interact_pointer_pos()
&& on_page_gap(&rects, &geo.bands, p)
{
let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space}));
} else
// Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing.
if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
mouse(app, ui, &resp, &rects, &layout, geo.scale);
@ -1080,7 +1213,8 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for (i, r) in rects.iter().enumerate() {
let d = if r.contains(p) {
0.0
// Pages overlap in the margins Hide White Space cuts: the one whose middle is nearer wins.
1e-3 * (p.y - r.center().y).abs() / r.height().max(1.0)
} else {
let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0);
let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0);
@ -1127,6 +1261,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
return;
}
// The press already selected the word or paragraph (and a drag may have grown it).
let released_clicks = if resp.triple_clicked() {
3
} else if resp.double_clicked() {
2
} else {
0
};
if released_clicks > 0 && app.canvas.unit_drag.as_ref().is_some_and(|d| d.clicks == released_clicks) {
app.canvas.unit_drag = None;
app.canvas.dragging = false;
return;
}
// Double-click in the header/footer area edits it; double-click in the body leaves it.
if resp.double_clicked() {
if let Some((s, header)) = layout.header_footer_at(page, y)
@ -1164,6 +1311,14 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
if pressed {
let now = ui.input(|i| i.time);
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
let clicks = press_count(app.canvas.last_press, now, p, delay, dist);
app.canvas.last_press = Some((now, p, clicks));
app.canvas.unit_drag = None;
// A double-click that enters or leaves a header/footer is handled on release.
let hf_switch = (layout.header_footer_at(page, y).is_some() && story == StoryRef::Body)
|| (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && layout.header_footer_at(page, y).is_none());
// Clicking into a footnote/endnote or a text box edits it; clicking outside a text box goes
// back to where it is. While editing a header/footer, only its own text boxes are in reach.
let story = {
@ -1217,8 +1372,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
app.canvas.column_drag = None;
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
if mods.command && !mods.shift {
let _ = app.run("select.sentence", json!({}));
// Double-click selects a word, triple-click a paragraph, Ctrl/⌘+click a sentence; holding
// the button and dragging then extends by whole units (Word, #549).
let unit = match clicks {
_ if mods.shift => None,
3 if !hf_switch => Some("paragraph"),
2 if !hf_switch => Some("word"),
1 if mods.command => Some("sentence"),
_ => None,
};
if let Some(unit) = unit {
let _ = app.run(&format!("select.{unit}"), json!({}));
let (start, end) = app.session.sel.ordered();
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: pos });
}
app.session.page_hint = page;
app.canvas.dragging = true;
@ -1235,6 +1401,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
);
}
} else if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some(d) = app.canvas.unit_drag.as_mut()
{
if let Some(pos) = layout.hit(page, x, y, story)
&& pos != d.last
{
d.last = pos.clone();
let params = json!({"unit": d.unit, "start": d.start, "end": d.end, "pos": pos});
let _ = app.run("select.dragUnit", params);
app.session.page_hint = page;
app.canvas.caret_visible_since = crate::now_ms();
}
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
if let Some(pos) = layout.hit(page, x, y, story)
&& pos != app.session.sel.focus
@ -1247,6 +1426,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
app.canvas.dragging = false;
app.canvas.column_drag = None;
app.canvas.unit_drag = None;
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
let _ = app.run("edit.pasteFormat", json!({}));
}
@ -1551,7 +1731,7 @@ fn part_kind(app: &WordApp, s: StoryRef) -> Option<PartKind> {
}
/// The story a text box's story sits in (where the box is), when the caret is in a text box.
fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option<StoryRef> {
pub(crate) fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option<StoryRef> {
match app.session.sel.focus.story {
StoryRef::Part(id) if in_text_box(app) => layout.text_box(id, app.session.page_hint).map(|o| o.story),
_ => None,
@ -1778,8 +1958,7 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
fn is_followable_link(url: &str) -> bool {
let Some((scheme, _)) = url.split_once(':') else { return false };
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
wordcraft_doc::link::is_web_or_mail_link(url)
}
#[cfg(test)]
@ -1822,6 +2001,87 @@ mod tests {
assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left);
}
/// #549: presses count as double/triple as they go down; slow or far presses start again.
#[test]
fn presses_count_up_to_three() {
let at = pos2(100.0, 100.0);
assert_eq!(press_count(None, 5.0, at, 0.3, 6.0), 1);
assert_eq!(press_count(Some((5.0, at, 1)), 5.1, at, 0.3, 6.0), 2);
assert_eq!(press_count(Some((5.1, at, 2)), 5.2, at + vec2(3.0, 0.0), 0.3, 6.0), 3);
assert_eq!(press_count(Some((5.2, at, 3)), 5.3, at, 0.3, 6.0), 3, "stays a triple");
assert_eq!(press_count(Some((5.0, at, 1)), 5.5, at, 0.3, 6.0), 1, "too slow");
assert_eq!(press_count(Some((5.0, at, 1)), 5.1, at + vec2(20.0, 0.0), 0.3, 6.0), 1, "too far");
assert_eq!(press_count(Some((5.0, at, 1)), 4.0, at, 0.3, 6.0), 1, "the clock went back");
}
/// #549: double-click a word and drag: the selection grows by whole words; triple-click and
/// drag: by whole paragraphs (Word's behaviour).
#[test]
fn multi_click_drag_extends_by_units() {
use egui::{Event, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "Alpha beta gamma delta\rSecond para\rThird para"})).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();
}
let screen = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: Pos| {
let c = h.state_mut().session.layout().caret(&pos).expect("caret");
page_to_screen(h.state_mut(), c.page, c.x + 1.0, c.top + c.height / 2.0).expect("on screen")
};
let button =
|at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let multi_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();
}
};
// Double-click inside "beta", drag into "gamma": "beta gamma " is selected.
let (from, to) = (screen(&mut h, Pos::body(0, 8)), screen(&mut h, Pos::body(0, 13)));
multi_drag(&mut h, from, to, 2);
let (s, e) = h.state().session.sel.ordered();
assert_eq!((s, e), (Pos::body(0, 6), Pos::body(0, 17)), "whole words");
// Let the double-click time pass, then triple-click the second paragraph and drag into the third.
for _ in 0..40 {
h.step();
}
let (from, to) = (screen(&mut h, Pos::body(1, 3)), screen(&mut h, Pos::body(2, 2)));
multi_drag(&mut h, from, to, 3);
let sel = h.state().session.sel.clone();
assert_eq!((sel.anchor, sel.focus), (Pos::body(1, 0), Pos::body(2, 10)), "whole paragraphs");
// A plain double-click (no drag) still selects just the word.
h.state_mut().run("select.collapse", json!({})).unwrap();
for _ in 0..40 {
h.step();
}
let at = screen(&mut h, Pos::body(0, 1));
multi_drag(&mut h, at, at, 2);
let (s, e) = h.state().session.sel.ordered();
assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6)));
}
/// #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]
@ -2054,4 +2314,36 @@ mod tests {
assert_eq!(visual, layout);
}
#[test]
fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() {
let scale = 1.0;
let sizes = [(612.0, 792.0); 3];
let (rects, _) = place_pages(&sizes, 0.0, scale, 1, vec2(800.0, 600.0));
// Body from 72 to 720 pt on every page.
let bodies = [(72.0, 720.0); 3];
let (moved, bands, h) = collapse_white_space(&rects, &bodies, scale, 600.0);
assert_eq!(bands[0].0, 0.0, "first page keeps its top");
assert_eq!(bands[2].1, 792.0, "last page keeps its bottom");
assert_eq!(bands[1], (66.0, 726.0));
// Bands abut with one seam, so the content is much shorter than three full pages.
assert_eq!(moved[1].min.y + bands[1].0, moved[0].min.y + bands[0].1 + SEAM);
assert!(h < rects[2].bottom() - 2.0 * 66.0 * 2.0);
// A point inside page 2's band maps to that page and the right page coordinates.
let p = pos2(moved[1].center().x, moved[1].min.y + 300.0);
assert_eq!(page_at_index(&moved, p), Some(1));
// Just above the seam is page 1's text area, just below is page 2's, though the rects overlap.
let seam = moved[0].min.y + bands[0].1;
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam - 2.0)), Some(0));
assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam + SEAM + 2.0)), Some(1));
assert!(on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam + 0.5)));
assert!(!on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam - 40.0)));
// Hostile numbers never panic.
let _ = collapse_white_space(&rects, &[(f32::NAN, f32::INFINITY), (-1e9, 1e9)], f32::NAN, f32::NAN);
let _ = collapse_white_space(&[], &[], 1.0, 1.0);
}
fn page_at_index(rects: &[Rect], p: Pos2) -> Option<usize> {
nearest_page(rects, p)
}
}

View File

@ -0,0 +1,847 @@
//! Colour menus and the Colors dialog (More Colors…, #606).
//!
//! Every colour drop-down (Font Color, Shading, Page Color, Shape Fill/Outline, pen colours, and
//! the colour fields inside dialogs) shows the theme and standard colours, a Recent Colors row and
//! a More Colors… item. More Colors… opens the Colors dialog: a Standard tab (a honeycomb of
//! colours and a row of greys) and a Custom tab (a hue × saturation field with a lightness
//! slider, RGB or HSL numbers and a hex box). Everything is drawn in code.
//!
//! What a pick does is a [`Target`]: run a command with the colour put into its params, or hand
//! the colour back to a dialog's field. The colour a command takes is any `RRGGBB`, so a custom
//! colour needs nothing new below the UI.
use egui::{Color32, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use wordcraft_doc::Rgb;
use crate::WordApp;
use crate::theme::{Tokens, c32};
/// How many colours Recent Colors keeps.
pub const RECENT_MAX: usize = 10;
// ---------------------------------------------------------------------------------------------
// Pure helpers
/// A colour typed by a person: `RRGGBB`, `#RRGGBB`, `#RGB`, `0xRRGGBB`, `rgb(r, g, b)` or three
/// numbers 0–255 separated by commas or spaces. Anything else is `None`.
pub fn parse(s: &str) -> Option<Rgb> {
let s = s.trim();
let hex = s.trim_start_matches('#');
let hex = hex.strip_prefix("0x").or_else(|| hex.strip_prefix("0X")).unwrap_or(hex);
if hex.len() == 6 {
if let Some(c) = Rgb::parse(hex) {
return Some(c);
}
} else if hex.len() == 3 && hex.is_ascii() {
let d = |i: usize| hex.get(i..i + 1).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|v| v * 17);
if let (Some(r), Some(g), Some(b)) = (d(0), d(1), d(2)) {
return Some(Rgb(r, g, b));
}
}
let lower = s.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb").map(|r| r.trim().trim_start_matches('(').trim_end_matches(')')).unwrap_or(&lower);
let parts: Vec<&str> = inner.split(|c: char| c == ',' || c.is_whitespace()).filter(|p| !p.is_empty()).collect();
if parts.len() != 3 {
return None;
}
let n = |i: usize| parts.get(i).and_then(|p| p.parse::<u8>().ok());
Some(Rgb(n(0)?, n(1)?, n(2)?))
}
/// Hue (degrees, 0–360), saturation and lightness (0–1) of a colour.
pub fn to_hsl(c: Rgb) -> (f32, f32, f32) {
let (r, g, b) = (c.0 as f32 / 255.0, c.1 as f32 / 255.0, c.2 as f32 / 255.0);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let d = max - min;
if d <= f32::EPSILON {
return (0.0, 0.0, l);
}
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
((h * 60.0).rem_euclid(360.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0))
}
/// The colour with hue `h` (degrees, any value wraps), saturation `s` and lightness `l` (0–1,
/// clamped). Non-finite input counts as 0.
pub fn from_hsl(h: f32, s: f32, l: f32) -> Rgb {
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let h = fin(h).rem_euclid(360.0);
let s = fin(s).clamp(0.0, 1.0);
let l = fin(l).clamp(0.0, 1.0);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let q = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
Rgb(q(r), q(g), q(b))
}
/// Puts `hex` first in a Recent Colors list (no repeats, at most [`RECENT_MAX`]). Junk is ignored.
pub fn push_recent(list: &mut Vec<String>, hex: &str) {
let Some(c) = parse(hex) else { return };
let hex = c.hex();
list.retain(|h| parse(h).map(Rgb::hex).as_deref() != Some(hex.as_str()));
list.insert(0, hex);
list.truncate(RECENT_MAX);
}
/// The Recent Colors to show from a saved list: valid colours only, no repeats, at most
/// [`RECENT_MAX`].
pub fn recent(list: &[String]) -> Vec<Rgb> {
let mut out: Vec<Rgb> = Vec::new();
for c in list.iter().filter_map(|h| parse(h)) {
if !out.contains(&c) {
out.push(c);
}
if out.len() >= RECENT_MAX {
break;
}
}
out
}
/// Rings of the Standard tab's honeycomb around its white centre.
pub const HONEYCOMB_RINGS: i32 = 6;
/// The Standard tab's honeycomb: axial cell coordinates `(q, r)` and their colours. The centre is
/// white; going out, colours get more saturated and then darker; the hue follows the angle.
pub fn honeycomb() -> Vec<(i32, i32, Rgb)> {
let n = HONEYCOMB_RINGS;
let mut cells = Vec::new();
for r in -n..=n {
for q in -n..=n {
let d = q.abs().max(r.abs()).max((q + r).abs());
if d > n {
continue;
}
let (x, y) = cell_xy(q, r);
let hue = (-y).atan2(x).to_degrees();
let t = d as f32 / n as f32;
cells.push((q, r, from_hsl(hue, 1.0, 1.0 - 0.75 * t)));
}
}
cells
}
/// The greys under the honeycomb, white to black.
pub fn greys() -> Vec<Rgb> {
(0..=14u32)
.map(|i| {
let v = (255 - (i * 255 / 14).min(255)) as u8;
Rgb(v, v, v)
})
.collect()
}
/// A cell's centre in units of the cell radius (pointy-top hexagons).
fn cell_xy(q: i32, r: i32) -> (f32, f32) {
(3f32.sqrt() * (q as f32 + r as f32 / 2.0), 1.5 * r as f32)
}
// ---------------------------------------------------------------------------------------------
// Targets
/// What a menu remembers as the split button's colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Remember {
Nothing,
FontColor,
Shading,
}
/// Where a picked colour goes.
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
/// Run `cmd` with the colour (`RRGGBB`) put into `params` at the JSON pointer `at`.
Run { cmd: String, params: Value, at: String, remember: Remember },
/// A dialog's colour field (see [`field_menu`]).
Field(Id),
}
impl Target {
/// `cmd` with params `{"color": RRGGBB}`.
pub fn cmd(cmd: &str) -> Target {
Target::Run { cmd: cmd.into(), params: json!({"color": null}), at: "/color".into(), remember: Remember::Nothing }
}
/// `cmd` with the colour at the JSON pointer `at` inside `params`.
pub fn at(cmd: &str, params: Value, at: &str) -> Target {
Target::Run { cmd: cmd.into(), params, at: at.into(), remember: Remember::Nothing }
}
pub fn remember(self, r: Remember) -> Target {
match self {
Target::Run { cmd, params, at, .. } => Target::Run { cmd, params, at, remember: r },
t => t,
}
}
/// The command and params a pick of `hex` runs (`None` for a field).
pub fn command(&self, hex: &str) -> Option<(String, Value)> {
let Target::Run { cmd, params, at, .. } = self else { return None };
let mut p = params.clone();
match p.pointer_mut(at) {
Some(slot) => *slot = json!(hex),
None => p["color"] = json!(hex),
}
Some((cmd.clone(), p))
}
}
/// Apply a picked colour to its target.
pub fn apply(app: &mut WordApp, ctx: &egui::Context, target: &Target, hex: &str) {
match target {
Target::Run { remember, .. } => {
match remember {
Remember::FontColor => app.canvas.last_font_color = hex.to_string(),
Remember::Shading => app.canvas.last_shading = hex.to_string(),
Remember::Nothing => {}
}
if let Some((cmd, p)) = target.command(hex) {
let _ = app.run(&cmd, p);
}
}
Target::Field(id) => {
ctx.data_mut(|d| d.insert_temp(*id, FieldResult(hex.to_string())));
ctx.request_repaint();
}
}
}
/// The colour a target has now (the dialog's Current swatch).
fn current_of(app: &mut WordApp, target: &Target) -> Option<Rgb> {
let Target::Run { cmd, .. } = target else { return None };
match cmd.as_str() {
"format.color" => {
let st = app.session.run("format.state", &json!({})).ok()?;
st.get("color").and_then(Value::as_str).and_then(parse)
}
_ => None,
}
}
// ---------------------------------------------------------------------------------------------
// The drop-down
/// A choice in a colour drop-down.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Pick {
Color(String),
More,
}
fn recent_id() -> Id {
Id::new("wordcraft.recentColors")
}
fn request_id() -> Id {
Id::new("wordcraft.moreColorsRequest")
}
fn open_flag_id() -> Id {
Id::new("wordcraft.colorsDialogOpen")
}
/// Removes and returns a temporary value.
fn take<T: Clone + Send + Sync + 'static>(d: &mut egui::util::IdTypeMap, id: Id) -> Option<T> {
let v = d.get_temp::<T>(id);
d.remove::<T>(id);
v
}
#[derive(Clone)]
struct FieldRequest(Id, Option<Rgb>);
#[derive(Clone)]
struct FieldResult(String);
/// The drop-down's body: theme and standard colours, Recent Colors, More Colors….
pub fn palette(ui: &mut Ui, theme: &[Rgb]) -> Option<Pick> {
let mut picked = crate::widgets::color_grid(ui, theme).map(Pick::Color);
let recent: Vec<String> = ui.data(|d| d.get_temp::<Vec<String>>(recent_id())).unwrap_or_default();
let recent = self::recent(&recent);
if !recent.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Recent Colors")).small().weak());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 0.0);
for c in recent {
if crate::widgets::swatch(ui, c) {
picked = Some(Pick::Color(c.hex()));
}
}
});
}
ui.add_space(2.0);
ui.separator();
if more_item(ui).clicked() {
picked = Some(Pick::More);
}
picked
}
/// The More Colors… row: a small rainbow wheel and the label.
fn more_item(ui: &mut Ui) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let c = pos2(r.min.x + 10.0, r.center().y);
for i in 0..12 {
let a0 = (i as f32) * std::f32::consts::TAU / 12.0;
let a1 = a0 + std::f32::consts::TAU / 12.0;
let p = |a: f32| c + vec2(a.cos(), a.sin()) * 6.5;
let col = c32(from_hsl(i as f32 * 30.0, 1.0, 0.5));
ui.painter().add(egui::Shape::convex_polygon(vec![c, p(a0), p(a1)], col, Stroke::NONE));
}
ui.painter().circle_stroke(c, 6.5, Stroke::new(0.5, t.border_strong));
ui.painter().text(
pos2(r.min.x + 22.0, r.center().y),
egui::Align2::LEFT_CENTER,
tl!("More Colors…"),
egui::TextStyle::Button.resolve(ui.style()),
t.text,
);
resp
}
/// A command colour drop-down's body: picks run `target`, More Colors… opens the Colors dialog.
pub fn menu(ui: &mut Ui, app: &mut WordApp, theme: &[Rgb], target: Target) {
match palette(ui, theme) {
Some(Pick::Color(hex)) => {
let ctx = ui.ctx().clone();
apply(app, &ctx, &target, &hex);
ui.close();
}
Some(Pick::More) => {
let current = current_of(app, &target);
app.colors = Some(ColorsDialog::new(current, target));
ui.close();
}
None => {}
}
}
/// A dialog field's drop-down body (the dialog has no access to the app): a pick sets `value`;
/// More Colors… asks for the Colors dialog, whose answer [`take_field`] collects. `id` must be
/// the same each frame.
pub fn field_menu(ui: &mut Ui, theme: &[Rgb], id: Id, value: &mut String) {
match palette(ui, theme) {
Some(Pick::Color(hex)) => {
*value = hex;
ui.close();
}
Some(Pick::More) => {
let current = parse(value);
ui.data_mut(|d| d.insert_temp(request_id(), FieldRequest(id, current)));
ui.close();
}
None => {}
}
}
/// The Colors dialog's answer for the field `id`, if it just closed with OK.
pub fn take_field(ctx: &egui::Context, id: Id, value: &mut String) {
if let Some(FieldResult(hex)) = ctx.data_mut(|d| take::<FieldResult>(d, id)) {
*value = hex;
}
}
/// Whether the Colors dialog was open last frame: dialogs under it leave Enter and Escape to it.
pub fn is_open(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<bool>(open_flag_id())).unwrap_or(false)
}
// ---------------------------------------------------------------------------------------------
// The Colors dialog
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorsTab {
Standard,
Custom,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Model {
Rgb,
Hsl,
}
/// The Colors dialog's state.
#[derive(Clone, Debug)]
pub struct ColorsDialog {
pub tab: ColorsTab,
/// The new colour.
pub color: Rgb,
/// Hue, saturation, lightness of `color`; the hue is kept through greys so dragging the
/// lightness back out of black or white returns to it.
pub hsl: (f32, f32, f32),
/// The colour the target had when the dialog opened.
pub current: Option<Rgb>,
pub model: Model,
/// The hex box's text (may be mid-edit).
pub hex: String,
pub target: Target,
}
impl ColorsDialog {
pub fn new(current: Option<Rgb>, target: Target) -> ColorsDialog {
let c = current.unwrap_or(Rgb::BLACK);
let mut d = ColorsDialog { tab: ColorsTab::Standard, color: c, hsl: (0.0, 0.0, 0.0), current, model: Model::Rgb, hex: String::new(), target };
d.set_rgb(c);
d
}
pub fn set_rgb(&mut self, c: Rgb) {
let (h, s, l) = to_hsl(c);
let h = if s > 0.0 && l > 0.0 && l < 1.0 { h } else { self.hsl.0 };
self.color = c;
self.hsl = (h, s, l);
self.hex = c.hex();
}
pub fn set_hsl(&mut self, h: f32, s: f32, l: f32) {
self.hsl = (h.rem_euclid(360.0), s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
self.color = from_hsl(self.hsl.0, self.hsl.1, self.hsl.2);
self.hex = self.color.hex();
}
/// Sets the colour from typed text; returns whether it was a colour.
pub fn set_text(&mut self, s: &str) -> bool {
match parse(s) {
Some(c) => {
let (h, sat, l) = to_hsl(c);
let h = if sat > 0.0 { h } else { self.hsl.0 };
self.color = c;
self.hsl = (h, sat, l);
true
}
None => false,
}
}
}
/// Draws the Colors dialog if it is open (and opens it for a dialog field that asked).
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(recent_id(), app.ui.recent_colors.clone()));
if let Some(FieldRequest(id, current)) = ctx.data_mut(|d| take::<FieldRequest>(d, request_id())) {
app.colors = Some(ColorsDialog::new(current, Target::Field(id)));
}
let Some(mut d) = app.colors.take() else {
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
return;
};
let resp = egui::Modal::new(Id::new("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
}
match done {
Some(ok) => {
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
if ok {
finish(app, ctx, &d);
}
if matches!(d.target, Target::Run { .. }) && app.dialog.is_none() {
app.canvas.want_focus = true;
}
}
None => {
ctx.data_mut(|m| m.insert_temp(open_flag_id(), true));
app.colors = Some(d);
}
}
}
/// OK: the colour joins Recent Colors and goes to the target.
pub fn finish(app: &mut WordApp, ctx: &egui::Context, d: &ColorsDialog) {
let hex = d.color.hex();
push_recent(&mut app.ui.recent_colors, &hex);
ctx.data_mut(|m| m.insert_temp(recent_id(), app.ui.recent_colors.clone()));
apply(app, ctx, &d.target, &hex);
}
/// The dialog's contents. `Some(true)` for OK, `Some(false)` for Cancel.
fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
let t = Tokens::get(ui.ctx());
ui.set_width(430.0);
ui.label(egui::RichText::new(tl!("Colors")).font(crate::theme::semibold(14.0)));
ui.add_space(6.0);
ui.horizontal(|ui| {
for (tab, label) in [(ColorsTab::Standard, "Standard"), (ColorsTab::Custom, "Custom")] {
if ui.selectable_label(d.tab == tab, tl!(label)).clicked() {
d.tab = tab;
}
}
});
ui.separator();
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(330.0);
match d.tab {
ColorsTab::Standard => standard(ui, d),
ColorsTab::Custom => custom(ui, d),
}
});
ui.vertical(|ui| {
ui.add_space(110.0);
preview(ui, d, &t);
});
});
let mut out = None;
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Cancel")).clicked() {
out = Some(false);
}
let okb = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR));
let typing = ui.ctx().memory(|m| m.focused().is_some());
if okb.clicked() || (!typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
out = Some(true);
}
});
});
out
}
/// New over Current, as two halves of one swatch.
fn preview(ui: &mut Ui, d: &ColorsDialog, t: &Tokens) {
ui.label(egui::RichText::new(tl!("New")).small());
let (r, _) = ui.allocate_exact_size(vec2(64.0, 72.0), Sense::hover());
let top = Rect::from_min_max(r.min, pos2(r.max.x, r.center().y));
let bottom = Rect::from_min_max(pos2(r.min.x, r.center().y), r.max);
ui.painter().rect_filled(top, 0.0, c32(d.color));
match d.current {
Some(c) => {
ui.painter().rect_filled(bottom, 0.0, c32(c));
}
None => {
ui.painter().rect_filled(bottom, 0.0, t.panel);
ui.painter().line_segment([bottom.left_bottom(), bottom.right_top()], Stroke::new(1.0, t.text_dim));
}
}
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
ui.label(egui::RichText::new(tl!("Current")).small());
}
/// Pointy-top hexagon corners around `c` with radius `s`.
fn hexagon(c: Pos2, s: f32) -> Vec<Pos2> {
(0..6)
.map(|i| {
let a = (60.0 * i as f32 - 30.0).to_radians();
c + vec2(a.cos(), a.sin()) * s
})
.collect()
}
fn standard(ui: &mut Ui, d: &mut ColorsDialog) {
let t = Tokens::get(ui.ctx());
ui.label(tl!("Colors:"));
let s = 11.0;
let n = HONEYCOMB_RINGS as f32;
let size = vec2(3f32.sqrt() * s * (2.0 * n + 1.0) + 2.0, s * (3.0 * n + 2.0) + 2.0);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), size.y), Sense::click());
let centre = rect.center();
let cells = honeycomb();
let at = |q: i32, r: i32| {
let (x, y) = cell_xy(q, r);
centre + vec2(x, y) * s
};
let hit = |p: Pos2, centres: Vec<(Pos2, Rgb)>| centres.into_iter().find(|(c, _)| c.distance(p) < s * 0.9).map(|(_, col)| col);
let mut selected = None;
for &(q, r, col) in &cells {
let c = at(q, r);
ui.painter().add(egui::Shape::convex_polygon(hexagon(c, s), c32(col), Stroke::new(0.5, t.border_strong)));
if col == d.color {
selected = Some(c);
}
}
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked())
&& let Some(col) = hit(p, cells.iter().map(|&(q, r, col)| (at(q, r), col)).collect())
{
d.set_rgb(col);
}
// Greys: one row of hexagons under the honeycomb.
ui.add_space(6.0);
let greys = greys();
let w = 3f32.sqrt() * s;
let (row, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), s * 2.0 + 2.0), Sense::click());
let x0 = row.center().x - w * (greys.len() as f32 - 1.0) / 2.0;
let gat = |i: usize| pos2(x0 + w * i as f32, row.center().y);
for (i, col) in greys.iter().enumerate() {
let c = gat(i);
ui.painter().add(egui::Shape::convex_polygon(hexagon(c, s), c32(*col), Stroke::new(0.5, t.border_strong)));
if *col == d.color {
selected = Some(c);
}
}
if let Some(p) = gresp.interact_pointer_pos().filter(|_| gresp.clicked())
&& let Some(col) = hit(p, greys.iter().enumerate().map(|(i, col)| (gat(i), *col)).collect())
{
d.set_rgb(col);
}
if let Some(c) = selected {
ui.painter().add(egui::Shape::closed_line(hexagon(c, s), Stroke::new(2.5, Color32::WHITE)));
ui.painter().add(egui::Shape::closed_line(hexagon(c, s + 1.5), Stroke::new(1.0, Color32::BLACK)));
}
}
fn custom(ui: &mut Ui, d: &mut ColorsDialog) {
let t = Tokens::get(ui.ctx());
ui.label(tl!("Colors:"));
ui.horizontal(|ui| {
// Hue across, saturation up; drawn at the middle lightness.
let (field, fresp) = ui.allocate_exact_size(vec2(280.0, 170.0), Sense::click_and_drag());
let mut mesh = egui::Mesh::default();
let (cols, rows) = (36u32, 6u32);
for j in 0..=rows {
for i in 0..=cols {
let (u, v) = (i as f32 / cols as f32, j as f32 / rows as f32);
let p = pos2(field.min.x + u * field.width(), field.min.y + v * field.height());
mesh.colored_vertex(p, c32(from_hsl(u * 360.0, 1.0 - v, 0.5)));
}
}
for j in 0..rows {
for i in 0..cols {
let a = j * (cols + 1) + i;
mesh.add_triangle(a, a + 1, a + cols + 1);
mesh.add_triangle(a + 1, a + cols + 2, a + cols + 1);
}
}
ui.painter().add(egui::Shape::mesh(mesh));
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Outside);
if let Some(p) = fresp.interact_pointer_pos().filter(|_| fresp.clicked() || fresp.dragged()) {
let u = ((p.x - field.min.x) / field.width()).clamp(0.0, 1.0);
let v = ((p.y - field.min.y) / field.height()).clamp(0.0, 1.0);
let l = if d.hsl.2 <= 0.0 || d.hsl.2 >= 1.0 { 0.5 } else { d.hsl.2 };
d.set_hsl(u * 359.9, 1.0 - v, l);
}
let m = pos2(field.min.x + d.hsl.0 / 360.0 * field.width(), field.min.y + (1.0 - d.hsl.1) * field.height());
ui.painter().circle_stroke(m, 5.0, Stroke::new(2.0, Color32::BLACK));
ui.painter().circle_stroke(m, 4.0, Stroke::new(1.0, Color32::WHITE));
// Lightness: white at the top, black at the bottom, through the picked hue and saturation.
let (bar, bresp) = ui.allocate_exact_size(vec2(16.0, 170.0), Sense::click_and_drag());
let mut mesh = egui::Mesh::default();
let steps = 16u32;
for k in 0..=steps {
let v = k as f32 / steps as f32;
let col = c32(from_hsl(d.hsl.0, d.hsl.1, 1.0 - v));
let y = bar.min.y + v * bar.height();
mesh.colored_vertex(pos2(bar.min.x, y), col);
mesh.colored_vertex(pos2(bar.max.x, y), col);
}
for k in 0..steps {
let a = k * 2;
mesh.add_triangle(a, a + 1, a + 2);
mesh.add_triangle(a + 1, a + 3, a + 2);
}
ui.painter().add(egui::Shape::mesh(mesh));
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Outside);
if let Some(p) = bresp.interact_pointer_pos().filter(|_| bresp.clicked() || bresp.dragged()) {
let v = ((p.y - bar.min.y) / bar.height()).clamp(0.0, 1.0);
d.set_hsl(d.hsl.0, d.hsl.1, 1.0 - v);
}
let y = bar.min.y + (1.0 - d.hsl.2) * bar.height();
let tip = pos2(bar.max.x + 2.0, y);
ui.painter().add(egui::Shape::convex_polygon(vec![tip, tip + vec2(7.0, -5.0), tip + vec2(7.0, 5.0)], t.text, Stroke::NONE));
ui.add_space(8.0);
});
ui.add_space(8.0);
egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
ui.label(tl!("Color model:"));
egui::ComboBox::from_id_salt("colors_model")
.selected_text(match d.model {
Model::Rgb => "RGB",
Model::Hsl => "HSL",
})
.width(90.0)
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.model, Model::Rgb, "RGB");
ui.selectable_value(&mut d.model, Model::Hsl, "HSL");
});
ui.end_row();
match d.model {
Model::Rgb => {
let mut c = [d.color.0, d.color.1, d.color.2];
let mut changed = false;
for (i, label) in ["Red:", "Green:", "Blue:"].into_iter().enumerate() {
ui.label(tl!(label));
if let Some(v) = c.get_mut(i) {
changed |= ui.add(egui::DragValue::new(v).range(0..=255).speed(1.0)).changed();
}
ui.end_row();
}
if changed {
d.set_rgb(Rgb(c[0], c[1], c[2]));
}
}
Model::Hsl => {
// Word-style 0–255 scales for all three.
let to255 = |v: f32| (v * 255.0).round().clamp(0.0, 255.0) as u8;
let mut v = [to255(d.hsl.0 / 360.0), to255(d.hsl.1), to255(d.hsl.2)];
let mut changed = false;
for (i, label) in ["Hue:", "Sat:", "Lum:"].into_iter().enumerate() {
ui.label(tl!(label));
if let Some(x) = v.get_mut(i) {
changed |= ui.add(egui::DragValue::new(x).range(0..=255).speed(1.0)).changed();
}
ui.end_row();
}
if changed {
d.set_hsl(v[0] as f32 / 255.0 * 360.0, v[1] as f32 / 255.0, v[2] as f32 / 255.0);
}
}
}
ui.label(tl!("Hex:"));
ui.horizontal(|ui| {
ui.label("#");
let mut text = d.hex.clone();
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(80.0).char_limit(16));
if r.changed() {
d.hex = text.clone();
d.set_text(&text);
}
if r.lost_focus() {
d.hex = d.color.hex();
}
});
ui.end_row();
});
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_engine::Session;
#[test]
fn parse_accepts_hex_short_hex_and_rgb_triples() {
assert_eq!(parse("1A2B3C"), Some(Rgb(0x1A, 0x2B, 0x3C)));
assert_eq!(parse(" #1a2b3c "), Some(Rgb(0x1A, 0x2B, 0x3C)));
assert_eq!(parse("0xFF8800"), Some(Rgb(0xFF, 0x88, 0x00)));
assert_eq!(parse("#f80"), Some(Rgb(0xFF, 0x88, 0x00)));
assert_eq!(parse("rgb(12, 34, 56)"), Some(Rgb(12, 34, 56)));
assert_eq!(parse("12 34 56"), Some(Rgb(12, 34, 56)));
for junk in ["", "#", "#12345", "GGGGGG", "rgb(1,2)", "256,0,0", "-1,0,0", "ééé", "#ééé", "1,2,3,4", "\u{0}"] {
assert_eq!(parse(junk), None, "{junk:?}");
}
}
#[test]
fn hsl_round_trips_every_colour_channel_step() {
for r in (0..=255u8).step_by(15) {
for g in (0..=255u8).step_by(17) {
for b in (0..=255u8).step_by(51) {
let c = Rgb(r, g, b);
let (h, s, l) = to_hsl(c);
assert!((0.0..360.0).contains(&h) && (0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&l));
assert_eq!(from_hsl(h, s, l), c);
}
}
}
assert_eq!(from_hsl(0.0, 1.0, 0.5), Rgb(255, 0, 0));
assert_eq!(from_hsl(120.0, 1.0, 0.5), Rgb(0, 255, 0));
assert_eq!(from_hsl(240.0, 1.0, 0.5), Rgb(0, 0, 255));
assert_eq!(from_hsl(-120.0, 1.0, 0.5), Rgb(0, 0, 255), "hue wraps");
assert_eq!(from_hsl(f32::NAN, f32::INFINITY, -3.0), Rgb(0, 0, 0), "junk input is clamped, never a panic");
}
#[test]
fn recent_colors_dedupe_and_cap() {
let mut list = vec!["junk".to_string()];
for i in 0..15u8 {
push_recent(&mut list, &Rgb(i, i, i).hex());
}
push_recent(&mut list, "#0e0e0e");
push_recent(&mut list, "not a colour");
assert_eq!(list.len(), RECENT_MAX);
assert_eq!(list.first().map(String::as_str), Some("0E0E0E"));
assert_eq!(list.iter().filter(|h| *h == "0E0E0E").count(), 1);
let saved = vec!["FF0000".to_string(), "ff0000".into(), "nope".into(), "00FF00".into()];
assert_eq!(recent(&saved), vec![Rgb(255, 0, 0), Rgb(0, 255, 0)]);
}
#[test]
fn honeycomb_is_white_in_the_middle_and_has_every_hue() {
let cells = honeycomb();
assert_eq!(cells.len(), 127, "6 rings around the centre");
assert_eq!(cells.iter().find(|(q, r, _)| (*q, *r) == (0, 0)).map(|c| c.2), Some(Rgb::WHITE));
let mid: Vec<Rgb> = cells.iter().filter(|(q, r, _)| q.abs().max(r.abs()).max((q + r).abs()) == 3).map(|c| c.2).collect();
assert!(mid.iter().any(|c| c.0 > 200 && c.1 < 100 && c.2 < 100), "a red");
assert!(mid.iter().any(|c| c.1 > 200 && c.0 < 100 && c.2 < 100), "a green");
assert!(mid.iter().any(|c| c.2 > 200 && c.0 < 100 && c.1 < 100), "a blue");
let g = greys();
assert_eq!((g.first().copied(), g.last().copied()), (Some(Rgb::WHITE), Some(Rgb::BLACK)));
}
#[test]
fn targets_put_the_colour_where_the_command_wants_it() {
assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"}))));
let glow = Target::at("shape.effects", json!({"glow": {"color": null, "size": 8.0}}), "/glow/color");
assert_eq!(glow.command("00FF00"), Some(("shape.effects".into(), json!({"glow": {"color": "00FF00", "size": 8.0}}))));
assert_eq!(Target::Field(Id::new("x")).command("00FF00"), None);
}
#[test]
fn dialog_keeps_hue_through_grey_and_reads_typed_colours() {
let mut d = ColorsDialog::new(Some(Rgb(255, 0, 0)), Target::cmd("format.color"));
d.set_hsl(200.0, 0.0, 0.5);
assert_eq!(d.hsl.0, 200.0, "grey keeps the hue");
d.set_hsl(d.hsl.0, 1.0, 0.5);
assert_eq!(d.color, from_hsl(200.0, 1.0, 0.5));
assert!(d.set_text("#123456"));
assert_eq!(d.color, Rgb(0x12, 0x34, 0x56));
assert!(!d.set_text("#12"), "an unfinished hex keeps the colour");
assert_eq!(d.color, Rgb(0x12, 0x34, 0x56));
}
/// #606: More Colors… for the font colour opens the Colors dialog with the selection's colour
/// as Current; OK applies the custom colour, remembers it on the split button and puts it
/// first in Recent Colors.
#[test]
fn custom_font_color_applies_and_joins_recent_colors() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let _ = app.run("document.setText", json!({"text": "Custom colour"}));
let _ = app.run("select.all", json!({}));
let _ = app.run("format.color", json!({"color": "112233"}));
let r = app.run("ui.dialog", json!({"name": "colors"}));
assert_eq!(r.ok().and_then(|v| v.get("dialog").cloned()), Some(json!("colors")));
assert!(!crate::mini_toolbar::visible(&app), "the mini toolbar stays out of the Colors dialog's way");
let mut d = app.colors.take().unwrap_or_else(|| panic!("the Colors dialog opens"));
assert_eq!(d.current, Some(Rgb(0x11, 0x22, 0x33)));
assert!(d.set_text("#7A3EC8"));
let ctx = egui::Context::default();
finish(&mut app, &ctx, &d);
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
assert_eq!(st.get("color"), Some(&json!("7A3EC8")));
assert_eq!(app.canvas.last_font_color, "7A3EC8");
assert_eq!(app.ui.recent_colors.first().map(String::as_str), Some("7A3EC8"));
// A dialog field gets the colour back instead.
let id = Id::new("field");
d.target = Target::Field(id);
finish(&mut app, &ctx, &d);
let mut value = String::new();
take_field(&ctx, id, &mut value);
assert_eq!(value, "7A3EC8");
}
}

View File

@ -8,7 +8,8 @@
//! real pointer input in screen points (reaches every widget)
//! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left)
//! - `ui.key {key, shift?, alt?, cmd?}`, `ui.text {text}`: keyboard input through egui
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, page?, scale?}`: page PNG
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG
//! (without `path`, both reply with the PNG as base64 `png`)
//! - `ui.parity`: feature catalog parity
use std::sync::mpsc::Sender;
@ -203,11 +204,19 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
"ui.render" => {
let path = s("path").unwrap_or("/tmp/wordcraft-page.png");
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1);
match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": f("scale").unwrap_or(2.0)})) {
Ok(v) => ok(v),
Err(e) => err(e),
let scale = f("scale").unwrap_or(2.0);
match s("path") {
Some(path) => match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})) {
Ok(v) => ok(v),
Err(e) => err(e),
},
// No path: the PNG travels in the reply. Never a fixed name in the shared
// temporary folder, where another user could have planted a link to a file.
None => match render_png(app, page, scale) {
Ok(v) => ok(v),
Err(e) => err(e),
},
}
}
"ui.resize" => {
@ -234,12 +243,27 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
}
}
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
/// MCP server's `ui.render`).
fn render_png(app: &WordApp, page: u64, scale: f32) -> Result<Value, String> {
let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1);
let l = app.session.export_layout();
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default());
let png = img.to_png();
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
}
/// The window screenshot: written to `path` when one was given, else base64 PNG in the reply
/// (never a fixed name in the shared temporary folder).
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
let path = path.unwrap_or("/tmp/wordcraft-screenshot.png");
let [w, h] = image.size;
let bytes: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes };
let png = rendered.to_png();
let Some(path) = path else {
return json!({"ok": true, "result": {"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": w, "height": h}});
};
#[cfg(target_arch = "wasm32")]
let _ = (&png, path);
#[cfg(not(target_arch = "wasm32"))]

View File

@ -159,7 +159,12 @@ pub enum Dialog {
first: f32,
before: f32,
after: f32,
/// Line spacing as a multiple; Exactly and At Least show as 1.0.
line: f32,
/// `line` when the dialog opened: OK keeps the paragraph's spacing, whatever its rule,
/// unless this changed (#467).
#[serde(skip)]
line_was: f32,
keep_next: bool,
keep_lines: bool,
page_break: bool,
@ -283,7 +288,7 @@ pub enum Dialog {
name: String,
/// Style id.
based_on: String,
/// Index into `regions` (see [`REGION_LABELS`]).
/// Index into `regions` (see `REGION_LABELS`).
region: usize,
regions: Box<[TableRegion; 7]>,
/// What the regions showed when the dialog opened (or the base style changed): only
@ -798,8 +803,16 @@ fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
let c = wordcraft_doc::Rgb::parse(value);
color_field(ui, theme, value, "No Color", "");
}
/// A colour field: a swatch and a drop-down with `none_label` (which sets `none_value`), the
/// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`.
pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) {
ui.horizontal(|ui| {
let id = ui.id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c {
@ -811,16 +824,13 @@ pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut
}
}
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() };
ui.menu_button(label, |ui| {
if ui.button(tl!("No Color")).clicked() {
value.clear();
ui.close();
}
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
*value = hex;
if ui.button(tl!(none_label)).clicked() {
*value = none_value.to_string();
ui.close();
}
crate::colors::field_menu(ui, theme, id, value);
});
});
}
@ -969,6 +979,7 @@ impl Dialog {
before: rp.space_before,
after: rp.space_after,
line,
line_was: line,
keep_next: rp.keep_next,
keep_lines: rp.keep_lines,
page_break: rp.page_break_before,
@ -1337,11 +1348,13 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
// Enter and Escape belong to the Colors dialog while it is open above this one.
let keys = !crate::colors::is_open(ui.ctx());
if ui.button(tl!("Cancel")).clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
r.1 = true;
}
let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR));
if okb.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
r.0 = true;
}
});
@ -1382,6 +1395,9 @@ fn font_props(d: &Dialog) -> serde_json::Map<String, Value> {
}
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
props.insert("color".into(), json!({"Rgb": [c.0, c.1, c.2]}));
} else if color.eq_ignore_ascii_case("auto") {
// Automatic, from the colour field's drop-down (#606).
props.insert("color".into(), json!("Auto"));
}
props
}
@ -1457,7 +1473,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.text_edit_singleline(size);
ui.end_row();
ui.label(tl!("Font color:"));
ui.text_edit_singleline(color);
let theme = app.session.doc.settings.theme_colors.clone();
color_field(ui, &theme, color, "Automatic", "auto");
ui.end_row();
ui.label(tl!("Spacing (pt):"));
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0));
@ -1503,6 +1520,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
before,
after,
line,
line_was,
keep_next,
keep_lines,
page_break,
@ -1620,7 +1638,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
*first,
*before,
*after,
*line,
(*line, *line_was),
[*keep_next, *keep_lines, *page_break, *widow],
(*opts, *opts_was),
);
@ -2762,11 +2780,12 @@ fn apply_paragraph(
first: f32,
before: f32,
after: f32,
line: f32,
line: (f32, f32),
flags: [bool; 4],
opts: ([bool; 4], [bool; 4]),
) {
let [keep_next, keep_lines, page_break, widow] = flags;
let (line, line_was) = line;
// Direction first: the alignment and indents are as seen on the page, and their logical
// values depend on it.
let _ = app.run(if rtl { "para.rtl" } else { "para.ltr" }, json!({}));
@ -2774,9 +2793,15 @@ fn apply_paragraph(
let (start, end) = if rtl { (right, left) } else { (left, right) };
let mut props = json!({
"indentLeft": start * crate::options::unit().pt_per_unit(), "indentRight": end * crate::options::unit().pt_per_unit(), "indentFirst": first * crate::options::unit().pt_per_unit(),
"spaceBefore": before, "spaceAfter": after, "lineSpacing": {"rule": "multiple", "value": line},
"spaceBefore": before, "spaceAfter": after,
"keepNext": keep_next, "keepLines": keep_lines, "pageBreakBefore": page_break, "widowControl": widow,
});
// The dialog edits only multiples: an untouched value keeps Exactly and At Least.
if line != line_was
&& let Some(m) = props.as_object_mut()
{
m.insert("lineSpacing".into(), json!({"rule": "multiple", "value": line}));
}
let (now, was) = opts;
for (key, (on, was)) in ["mirrorIndents", "contextualSpacing", "suppressLineNumbers", "suppressHyphens"].into_iter().zip(now.into_iter().zip(was))
{
@ -3135,14 +3160,40 @@ mod tests {
/// Open the Paragraph dialog, change its options (`opts`) with `edit` and press OK.
fn ok_with(app: &mut WordApp, edit: impl FnOnce(&mut [bool; 4])) {
let Some(Dialog::Paragraph {
rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, mut opts, ..
rtl,
align,
left,
right,
first,
before,
after,
line,
line_was,
keep_next,
keep_lines,
page_break,
widow,
mut opts,
..
}) = Dialog::open("paragraph", app)
else {
panic!("no paragraph dialog")
};
let was = opts;
edit(&mut opts);
apply_paragraph(app, rtl, &align, left, right, first, before, after, line, [keep_next, keep_lines, page_break, widow], (opts, was));
apply_paragraph(
app,
rtl,
&align,
left,
right,
first,
before,
after,
(line, line_was),
[keep_next, keep_lines, page_break, widow],
(opts, was),
);
}
#[test]
@ -3191,6 +3242,60 @@ mod tests {
}
}
#[test]
fn paragraph_dialog_keeps_fixed_line_spacing() {
use wordcraft_doc::props::LineSpacing;
for (cmd, rule) in [(json!({"exactly": 24}), LineSpacing::Exactly(24.0)), (json!({"atLeast": 24}), LineSpacing::AtLeast(24.0))] {
// OK without changes, and OK after changing only Keep with next (#467).
let mut app = app_with("Example");
let _ = app.run("para.lineSpacing", cmd.clone());
ok_unchanged(&mut app);
assert_eq!(props(&app).line_spacing, Some(rule), "unchanged OK, {cmd}");
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, line_was, keep_lines, page_break, widow, .. }) =
Dialog::open("paragraph", &mut app)
else {
panic!("no paragraph dialog")
};
apply_paragraph(
&mut app,
rtl,
&align,
left,
right,
first,
before,
after,
(line, line_was),
[true, keep_lines, page_break, widow],
([false; 4], [false; 4]),
);
let p = props(&app);
assert_eq!((p.line_spacing, p.keep_next), (Some(rule), Some(true)), "Keep with next, {cmd}");
}
// A changed value still sets a multiple.
let mut app = app_with("Example");
let _ = app.run("para.lineSpacing", json!({"exactly": 24}));
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line_was, keep_next, keep_lines, page_break, widow, .. }) =
Dialog::open("paragraph", &mut app)
else {
panic!("no paragraph dialog")
};
apply_paragraph(
&mut app,
rtl,
&align,
left,
right,
first,
before,
after,
(1.5, line_was),
[keep_next, keep_lines, page_break, widow],
([false; 4], [false; 4]),
);
assert_eq!(props(&app).line_spacing, Some(LineSpacing::Multiple(1.5)));
}
#[test]
fn font_dialog_sets_the_asian_and_latin_fonts_separately() {
// #505: the Asian text font box and the Latin text font box each set their own font, and
@ -3223,7 +3328,7 @@ mod tests {
#[test]
fn paragraph_dialog_changes_direction() {
let mut app = app_with("Hello");
let Some(Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) =
let Some(Dialog::Paragraph { align, left, right, first, before, after, line, line_was, keep_next, keep_lines, page_break, widow, .. }) =
Dialog::open("paragraph", &mut app)
else {
panic!()
@ -3238,7 +3343,7 @@ mod tests {
first,
before,
after,
line,
(line, line_was),
[keep_next, keep_lines, page_break, widow],
([false; 4], [false; 4]),
);

View File

@ -416,8 +416,10 @@ fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)]) {
/// A colour: `none_label` (by author, or `auto` when given) or a colour from the grid. `value` is
/// hex.
fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, auto: Option<wordcraft_doc::Rgb>) {
let c = wordcraft_doc::Rgb::parse(value);
ui.horizontal(|ui| {
let id = ui.id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
let t = Tokens::get(ui.ctx());
match c.or(auto) {
@ -440,10 +442,7 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab
value.clear();
ui.close();
}
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
*value = hex;
ui.close();
}
crate::colors::field_menu(ui, theme, id, value);
});
});
}

View File

@ -33,6 +33,8 @@ pub(crate) enum AfterPick {
SaveAs(AfterSave),
/// Save a copy there in this format (`pdf`, `docx`, `png`…).
Export { ext: String },
/// Write the PDF of these print settings there (File › Print › Save these pages as PDF).
PrintPdf { settings: Value },
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
Recipients,
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
@ -151,6 +153,16 @@ impl WordApp {
}
r
}
AfterPick::PrintPdf { settings } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let mut params = settings;
params["path"] = json!(path);
let r = self.run("file.printPdf", params);
if r.is_ok() {
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
r
}
AfterPick::SaveAs(after) => {
let saved = picked.is_some_and(|path| {
self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true))
@ -191,6 +203,14 @@ impl WordApp {
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
}
/// File › Print › Save these pages as PDF: ask where, then write the pages the print
/// settings choose.
pub(crate) fn print_pdf_dialog(&mut self) {
let name = format!("{}.pdf", self.title_stem());
let settings = self.ui.print.params();
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::PrintPdf { settings });
}
pub(crate) fn open_dialog(&mut self) {
if let Some(f) = &self.services.open_async {
f("document");

View File

@ -378,6 +378,7 @@
Export a copy to share (PDF, Word document) Exporta una copia para compartir (PDF, documento de Word)
Exported {path} Exportado: {path}
Extend Selection Ampliar selección
Extend Selection by Units Ampliar selección por unidades
Field Campo
File Archivo
Find Buscar
@ -1414,6 +1415,8 @@
Formatted Con formato
Interface size: Tamaño de la interfaz:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte.
Print Markup Imprimir marcas
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentarios junto a cada página y cambios marcados en PDF, impresiones e imágenes; una lista de comentarios al final de páginas web, Markdown y texto.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Restricciones de formato
@ -1534,6 +1537,19 @@
New Group Nuevo grupo
Rename… Cambiar nombre…
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
Recent Colors Colores recientes
More Colors… Más colores…
Standard Estándar
Current Actual
Colors: Colores:
Color model: Modelo de color:
Red: Rojo:
Green: Verde:
Blue: Azul:
Hue: Matiz:
Sat: Sat.:
Lum: Lum.:
Hex: Hex.:
Accessibility Pane Panel de accesibilidad
Alt Text Pane Panel de texto alternativo
Alt\nText Texto\nalternativo
@ -1682,6 +1698,22 @@
Continuous numbering Numeración continua
Apply Aplicar
Convert Notes Convertir notas
Hide White Space Ocultar espacio en blanco
All pages Todas las páginas
Current page Página actual
Selected pages Páginas seleccionadas
Custom range Intervalo personalizado
All pages in the range Todas las páginas del intervalo
Odd pages only Solo páginas impares
Even pages only Solo páginas pares
Copies Copias
Collate copies Intercalar copias
Pages like 1-3, 5 Páginas como 1-3, 5
{count} of {total} pages will print Se imprimirán {count} de {total} páginas
Save these pages as PDF… Guardar estas páginas como PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. La impresora, la impresión a doble cara y las páginas por hoja se eligen en ese cuadro de diálogo.
Previous Page Página anterior
Print to PDF Imprimir en PDF
Add Shape Agregar forma
Change Colors Cambiar colores
Change Layout Cambiar diseño

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -251,6 +251,7 @@
Export a copy to share (PDF, Word document) Ekspordi koopia jagamiseks (PDF, Wordi dokument)
Exported {path} Eksporditud: {path}
Extend Selection Laienda valikut
Extend Selection by Units Laienda valikut ühikute kaupa
Field Väli
File Fail
Find Otsi
@ -1400,6 +1401,8 @@
Formatted Vormindatud
Interface size: Kasutajaliidese suurus:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
Print Markup Prindi märgistus
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentaarid iga lehe kõrval ja jälitatud muudatused PDF-ides, väljatrükkides ja piltides; kommentaaride loend veebilehtede, Markdowni ja teksti lõpus.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Vormingupiirangud
@ -1520,6 +1523,19 @@
Ribbon Lint
Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba:
Ribbon tabs Lindi vahekaardid
Recent Colors Viimati kasutatud värvid
More Colors… Rohkem värve…
Standard Standard
Current Praegune
Colors: Värvid:
Color model: Värvimudel:
Red: Punane:
Green: Roheline:
Blue: Sinine:
Hue: Toon:
Sat: Küllastus:
Lum: Heledus:
Hex: Hex:
Right-to-Left Table Paremalt vasakule tabel
Right-to-Left Section Paremalt vasakule jaotis
Table direction: Tabeli suund:
@ -1643,6 +1659,22 @@
Continuous numbering Pidev nummerdus
Apply Rakenda
Convert Notes Teisenda märkused
Hide White Space Peida tühi ala lehtede vahel
All pages Kõik leheküljed
Current page Praegune lehekülg
Selected pages Valitud leheküljed
Custom range Kohandatud vahemik
All pages in the range Kõik vahemiku leheküljed
Odd pages only Ainult paaritud leheküljed
Even pages only Ainult paarisleheküljed
Copies Koopiad
Collate copies Järjesta koopiad
Pages like 1-3, 5 Leheküljed, nt 1-3, 5
{count} of {total} pages will print Prinditakse {count} lehekülge {total}-st
Save these pages as PDF… Salvesta need leheküljed PDF-ina…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Printer, kahepoolne printimine ja lehekülgi lehe kohta valitakse selles dialoogis.
Previous Page Eelmine lehekülg
Print to PDF Prindi PDF-i
Add Shape Lisa kujund
Change Colors Muuda värve
Change Layout Muuda paigutust

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -380,6 +380,7 @@
Export PDF PDF にエクスポート
Exported {path} {path} にエクスポートしました
Extend Selection 選択範囲の拡張
Extend Selection by Units 単位ごとに選択範囲を拡張
Field フィールド
File ファイル
Find 検索
@ -1398,6 +1399,8 @@
Formatted 書式変更
Interface size: 表示サイズ:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。
Print Markup 変更履歴とコメントを印刷
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF、印刷、画像では各ページの横にコメントと変更履歴を表示し、Web ページ、Markdown、テキストでは末尾にコメント一覧を付けます。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 書式の制限
@ -1518,6 +1521,19 @@
New Group 新しいグループ
Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Recent Colors 最近使用した色
More Colors… その他の色…
Standard 標準
Current 現在
Colors: 色:
Color model: カラー モデル:
Red: 赤:
Green: 緑:
Blue: 青:
Hue: 色合い:
Sat: 鮮やかさ:
Lum: 明るさ:
Hex: 16 進数:
Accessibility Pane アクセシビリティ ウィンドウ
Alt Text Pane 代替テキスト ウィンドウ
Alt\nText 代替\nテキスト
@ -1666,6 +1682,22 @@
Continuous numbering 通し番号
Apply 適用
Convert Notes 注の変換
Hide White Space 余白を非表示
All pages すべてのページ
Current page 現在のページ
Selected pages 選択したページ
Custom range ページ範囲を指定
All pages in the range 範囲内のすべてのページ
Odd pages only 奇数ページのみ
Even pages only 偶数ページのみ
Copies 部数
Collate copies 部単位で印刷
Pages like 1-3, 5 ページ指定(例: 1-3, 5)
{count} of {total} pages will print {total} ページ中 {count} ページを印刷します
Save these pages as PDF… このページを PDF として保存…
The printer, two-sided printing and pages per sheet are chosen in that dialog. プリンター、両面印刷、1 枚あたりのページ数は、そのダイアログで選択します。
Previous Page 前のページ
Print to PDF PDF に印刷
Add Shape 図形の追加
Change Colors 色の変更
Change Layout レイアウトの変更

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -239,7 +239,7 @@ pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// `s` in the current language as worded for `context` (one of [`catalog::CONTEXTS`]), falling
/// `s` in the current language as worded for `context` (one of `catalog::CONTEXTS`), falling
/// back to the plain translation and then to English.
pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
trc(current(), context, s)

View File

@ -381,6 +381,7 @@
Export PDF Exportar PDF
Exported {path} Exportado {path}
Extend Selection Expandir seleção
Extend Selection by Units Expandir seleção por unidades
Field Campo
File Arquivo
Find Localizar
@ -1410,6 +1411,8 @@
Formatted Formatado
Interface size: Tamanho da interface:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado.
Print Markup Imprimir marcação
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentários ao lado de cada página e alterações controladas em PDFs, impressões e imagens; uma lista de comentários no final de páginas da web, Markdown e texto.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Restrições de formatação
@ -1530,6 +1533,19 @@
New Group Novo Grupo
Rename… Renomear…
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
Recent Colors Cores recentes
More Colors… Mais cores…
Standard Padrão
Current Atual
Colors: Cores:
Color model: Modelo de cor:
Red: Vermelho:
Green: Verde:
Blue: Azul:
Hue: Matiz:
Sat: Sat.:
Lum: Lum.:
Hex: Hex.:
Accessibility Pane Painel de acessibilidade
Alt Text Pane Painel de texto alternativo
Alt\nText Texto\nalternativo
@ -1678,6 +1694,22 @@
Continuous numbering Numeração contínua
Apply Aplicar
Convert Notes Converter notas
Hide White Space Ocultar espaço em branco
All pages Todas as páginas
Current page Página atual
Selected pages Páginas selecionadas
Custom range Intervalo personalizado
All pages in the range Todas as páginas do intervalo
Odd pages only Somente páginas ímpares
Even pages only Somente páginas pares
Copies Cópias
Collate copies Agrupar cópias
Pages like 1-3, 5 Páginas como 1-3, 5
{count} of {total} pages will print {count} de {total} páginas serão impressas
Save these pages as PDF… Salvar estas páginas como PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. A impressora, a impressão frente e verso e as páginas por folha são escolhidas nessa caixa de diálogo.
Previous Page Página anterior
Print to PDF Imprimir em PDF
Add Shape Adicionar forma
Change Colors Alterar cores
Change Layout Alterar layout

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -253,6 +253,7 @@
Export PDF Экспорт в PDF
Exported {path} Экспортировано: {path}
Extend Selection Расширить выделение
Extend Selection by Units Расширить выделение по единицам
Field Поле
File Файл
Find Найти
@ -1789,6 +1790,21 @@ preview Title Пример
Left Only Только слева
Right Only Только справа
Largest Only Только по большей стороне
Print Markup Печатать исправления и примечания
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста.
Recent Colors Последние цвета
More Colors… Другие цвета…
Standard Обычные
Current Текущий
Colors: Цвета:
Color model: Цветовая модель:
Red: Красный:
Green: Зеленый:
Blue: Синий:
Hue: Оттенок:
Sat: Насыщенность:
Lum: Яркость:
Hex: Hex:
Accessibility Pane Область специальных возможностей
Alt Text Pane Область замещающего текста
Alt\nText Замещающий\nтекст
@ -2111,6 +2127,22 @@ preview Title Пример
Continuous numbering Сквозная нумерация
Apply Применить
Convert Notes Преобразовать сноски
Hide White Space Скрыть пустое место между страницами
All pages Все страницы
Current page Текущая страница
Selected pages Выбранные страницы
Custom range Свой диапазон
All pages in the range Все страницы диапазона
Odd pages only Только нечётные страницы
Even pages only Только чётные страницы
Copies Копии
Collate copies Разобрать по копиям
Pages like 1-3, 5 Страницы, например 1-3, 5
{count} of {total} pages will print Будет напечатано страниц: {count} из {total}
Save these pages as PDF… Сохранить эти страницы как PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двусторонняя печать и число страниц на листе выбираются в этом диалоге.
Previous Page Предыдущая страница
Print to PDF Печать в PDF
# Word XML Document (#548).
Word XML Document (*.xml) Документ Word XML (*.xml)

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -428,6 +428,7 @@
Export PDF Izvezi PDF
Exported {path} Izvezeno: {path}
Extend Selection Proširi izbor
Extend Selection by Units Proširi izbor po jedinicama
Field Polje
File Fajl
Find Pronađi
@ -1408,6 +1409,8 @@
Formatted Formatirano
Interface size: Veličina interfejsa:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno.
Print Markup Štampaj oznake
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentari pored svake stranice i praćene izmene u PDF-ovima, otiscima i slikama; lista komentara na kraju veb-stranica, Markdown-a i teksta.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Ograničenja oblikovanja
@ -1528,6 +1531,19 @@
New Group Nova grupa
Rename… Preimenuj…
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
Recent Colors Nedavne boje
More Colors… Još boja…
Standard Standardne
Current Trenutna
Colors: Boje:
Color model: Model boja:
Red: Crvena:
Green: Zelena:
Blue: Plava:
Hue: Nijansa:
Sat: Zasićenost:
Lum: Osvetljenost:
Hex: Heks:
Accessibility Pane Okno pristupačnosti
Alt Text Pane Okno alternativnog teksta
Alt\nText Alternativni\ntekst
@ -1676,6 +1692,22 @@
Continuous numbering Neprekidna numeracija
Apply Primeni
Convert Notes Pretvori napomene
Hide White Space Sakrij prazan prostor
All pages Sve stranice
Current page Trenutna stranica
Selected pages Izabrane stranice
Custom range Prilagođeni opseg
All pages in the range Sve stranice u opsegu
Odd pages only Samo neparne stranice
Even pages only Samo parne stranice
Copies Kopije
Collate copies Složi kopije
Pages like 1-3, 5 Stranice poput 1-3, 5
{count} of {total} pages will print Štampaće se {count} od {total} stranica
Save these pages as PDF… Sačuvaj ove stranice kao PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Štampač, obostrana štampa i broj stranica po listu biraju se u tom dijalogu.
Previous Page Prethodna stranica
Print to PDF Štampaj u PDF
Add Shape Dodaj oblik
Change Colors Promeni boje
Change Layout Promeni raspored

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -432,6 +432,7 @@
Export PDF Извези PDF
Exported {path} Извезено: {path}
Extend Selection Прошири избор
Extend Selection by Units Прошири избор по јединицама
Field Поље
File Фајл
Find Пронађи
@ -1412,6 +1413,8 @@
Formatted Форматирано
Interface size: Величина интерфејса:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно.
Print Markup Штампај ознаке
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари поред сваке странице и праћене измене у PDF-овима, отисцима и сликама; листа коментара на крају веб-страница, Markdown-а и текста.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Ограничења обликовања
@ -1532,6 +1535,19 @@
New Group Нова група
Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Recent Colors Недавне боје
More Colors… Још боја…
Standard Стандардне
Current Тренутна
Colors: Боје:
Color model: Модел боја:
Red: Црвена:
Green: Зелена:
Blue: Плава:
Hue: Нијанса:
Sat: Засићеност:
Lum: Осветљеност:
Hex: Хекс:
Accessibility Pane Окно приступачности
Alt Text Pane Окно алтернативног текста
Alt\nText Алтернативни\nтекст
@ -1680,6 +1696,22 @@
Continuous numbering Непрекидна нумерација
Apply Примени
Convert Notes Претвори напомене
Hide White Space Сакриј празан простор
All pages Све странице
Current page Тренутна страница
Selected pages Изабране странице
Custom range Прилагођени опсег
All pages in the range Све странице у опсегу
Odd pages only Само непарне странице
Even pages only Само парне странице
Copies Копије
Collate copies Сложи копије
Pages like 1-3, 5 Странице попут 1-3, 5
{count} of {total} pages will print Штампаће се {count} од {total} страница
Save these pages as PDF… Сачувај ове странице као PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Штампач, обострана штампа и број страница по листу бирају се у том дијалогу.
Previous Page Претходна страница
Print to PDF Штампај у PDF
Add Shape Додај облик
Change Colors Промени боје
Change Layout Промени распоред

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -252,6 +252,7 @@
Export a copy to share (PDF, Word document) Paylaşmak için bir kopya dışa aktarın (PDF, Word belgesi)
Exported {path} Dışa aktarıldı: {path}
Extend Selection Seçimi Genişlet
Extend Selection by Units Seçimi Birimlere Göre Genişlet
Field Alan
File Dosya
Find Bul
@ -1407,6 +1408,21 @@
Left Only Yalnızca Sol
Right Only Yalnızca Sağ
Largest Only Yalnızca En Geniş
Print Markup İşaretlemeyi Yazdır
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi.
Recent Colors Son Kullanılan Renkler
More Colors… Diğer Renkler…
Standard Standart
Current Geçerli
Colors: Renkler:
Color model: Renk modeli:
Red: Kırmızı:
Green: Yeşil:
Blue: Mavi:
Hue: Ton:
Sat: Doygunluk:
Lum: Parlaklık:
Hex: Onaltılık:
Accessibility Pane Erişilebilirlik Bölmesi
Alt Text Pane Alternatif Metin Bölmesi
Alt\nText Alternatif\nMetin
@ -1733,6 +1749,22 @@
Continuous numbering Sürekli numaralandırma
Apply Uygula
Convert Notes Notları Dönüştür
Hide White Space Boşluğu gizle
All pages Tüm sayfalar
Current page Geçerli sayfa
Selected pages Seçili sayfalar
Custom range Özel aralık
All pages in the range Aralıktaki tüm sayfalar
Odd pages only Yalnızca tek sayfalar
Even pages only Yalnızca çift sayfalar
Copies Kopya
Collate copies Kopyaları harmanla
Pages like 1-3, 5 Sayfalar, örn. 1-3, 5
{count} of {total} pages will print {total} sayfadan {count} sayfa yazdırılacak
Save these pages as PDF… Bu sayfaları PDF olarak kaydet…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Yazıcı, çift taraflı yazdırma ve yaprak başına sayfa sayısı o iletişim kutusunda seçilir.
Previous Page Önceki sayfa
Print to PDF PDF'ye yazdır
# Word XML Document (#548).
Word XML Document (*.xml) Word XML Belgesi (*.xml)

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -390,6 +390,7 @@
Export PDF Експортувати PDF
Exported {path} Експортовано {path}
Extend Selection Розширити виділення
Extend Selection by Units Розширити виділення за одиницями
Field Поле
File Файл
Find Знайти
@ -1410,6 +1411,8 @@
Formatted Відформатовано
Interface size: Розмір інтерфейсу:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо.
Print Markup Друкувати примітки та виправлення
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментарі біля кожної сторінки й відстежені зміни в PDF, друку та зображеннях; список коментарів наприкінці вебсторінок, Markdown і тексту.
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. Обмеження форматування
@ -1530,6 +1533,19 @@
New Group Нова група
Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Recent Colors Останні кольори
More Colors… Інші кольори…
Standard Стандартні
Current Поточний
Colors: Кольори:
Color model: Колірна модель:
Red: Червоний:
Green: Зелений:
Blue: Синій:
Hue: Відтінок:
Sat: Насиченість:
Lum: Яскравість:
Hex: Hex:
Accessibility Pane Панель доступності
Alt Text Pane Панель альтернативного тексту
Alt\nText Альтернативний\nтекст
@ -1678,6 +1694,22 @@
Continuous numbering Наскрізна нумерація
Apply Застосувати
Convert Notes Перетворити примітки
Hide White Space Приховати порожній простір
All pages Усі сторінки
Current page Поточна сторінка
Selected pages Вибрані сторінки
Custom range Власний діапазон
All pages in the range Усі сторінки діапазону
Odd pages only Лише непарні сторінки
Even pages only Лише парні сторінки
Copies Копії
Collate copies Розсортувати копії
Pages like 1-3, 5 Сторінки, наприклад 1-3, 5
{count} of {total} pages will print Буде надруковано {count} зі сторінок: {total}
Save these pages as PDF… Зберегти ці сторінки як PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двобічний друк і кількість сторінок на аркуші вибирають у цьому діалозі.
Previous Page Попередня сторінка
Print to PDF Друк у PDF
Add Shape Додати фігуру
Change Colors Змінити кольори
Change Layout Змінити макет

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -392,6 +392,7 @@
Export PDF 导出 PDF
Exported {path} 已导出 {path}
Extend Selection 扩展选定范围
Extend Selection by Units 按单位扩展选定范围
Field 域
File 文件
Find 查找
@ -1410,6 +1411,8 @@
Formatted 已设置格式
Interface size: 界面大小:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。
Print Markup 打印标记
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、打印和图像中于每页旁显示批注和修订;在网页、Markdown 和文本末尾附上批注列表。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 格式限制
@ -1530,6 +1533,19 @@
New Group 新建组
Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Recent Colors 最近使用的颜色
More Colors… 其他颜色…
Standard 标准
Current 当前
Colors: 颜色:
Color model: 颜色模式:
Red: 红色:
Green: 绿色:
Blue: 蓝色:
Hue: 色调:
Sat: 饱和度:
Lum: 亮度:
Hex: 十六进制:
Accessibility Pane 辅助功能窗格
Alt Text Pane 替代文字窗格
Alt\nText 替代\n文字
@ -1678,6 +1694,22 @@
Continuous numbering 连续编号
Apply 应用
Convert Notes 转换注释
Hide White Space 隐藏空白
All pages 所有页面
Current page 当前页
Selected pages 所选页面
Custom range 自定义范围
All pages in the range 范围内的所有页面
Odd pages only 仅奇数页
Even pages only 仅偶数页
Copies 份数
Collate copies 逐份打印
Pages like 1-3, 5 页码,如 1-3, 5
{count} of {total} pages will print 将打印 {total} 页中的 {count} 页
Save these pages as PDF… 将这些页面另存为 PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. 打印机、双面打印和每张页数在该对话框中选择。
Previous Page 上一页
Print to PDF 打印到 PDF
Add Shape 添加形状
Change Colors 更改颜色
Change Layout 更改布局

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -381,6 +381,7 @@
Export PDF 匯出 PDF
Exported {path} 已匯出 {path}
Extend Selection 擴大選取範圍
Extend Selection by Units 按單位擴大選取範圍
Field 功能變數
File 檔案
Find 尋找
@ -1399,6 +1400,8 @@
Formatted 已設定格式
Interface size: 介面大小:
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。
Print Markup 列印標記
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、列印和影像中於每頁旁顯示註解和修訂;在網頁、Markdown 和文字末尾附上註解清單。
# Compare / Combine, AutoCorrect Options and Restrict Editing (#412)
1. Formatting restrictions 1. 格式設定限制
@ -1519,6 +1522,19 @@
New Group 新增群組
Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Recent Colors 最近使用的色彩
More Colors… 其他色彩…
Standard 標準
Current 目前
Colors: 色彩:
Color model: 色彩模式:
Red: 紅色:
Green: 綠色:
Blue: 藍色:
Hue: 色調:
Sat: 飽和度:
Lum: 亮度:
Hex: 十六進位:
Accessibility Pane 協助工具窗格
Alt Text Pane 替代文字窗格
Alt\nText 替代\n文字
@ -1667,6 +1683,22 @@
Continuous numbering 連續編號
Apply 套用
Convert Notes 轉換附註
Hide White Space 隱藏空白
All pages 所有頁面
Current page 目前頁面
Selected pages 所選頁面
Custom range 自訂範圍
All pages in the range 範圍內的所有頁面
Odd pages only 僅奇數頁
Even pages only 僅偶數頁
Copies 份數
Collate copies 逐份列印
Pages like 1-3, 5 頁碼,如 1-3, 5
{count} of {total} pages will print 將列印 {total} 頁中的 {count} 頁
Save these pages as PDF… 將這些頁面另存為 PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. 印表機、雙面列印和每張頁數在該對話方塊中選擇。
Previous Page 上一頁
Print to PDF 列印為 PDF
Add Shape 新增圖案
Change Colors 變更色彩
Change Layout 變更版面配置

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -18,6 +18,7 @@ pub mod a11y_pane;
pub mod backstage;
pub mod canvas;
pub mod chrome;
pub mod colors;
pub mod content_controls;
pub mod control;
pub mod credits;
@ -49,6 +50,7 @@ pub mod options;
pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod print_page;
pub mod printing;
pub mod read_aloud;
pub mod ribbon;
@ -111,10 +113,15 @@ pub struct UiState {
pub tab: String,
pub backstage: bool,
pub backstage_page: String,
/// File › Print settings (#641); not saved.
#[serde(skip)]
pub print: print_page::PrintSettings,
pub ribbon_collapsed: bool,
pub recent: Vec<String>,
/// Symbols inserted from the Symbol dialog, most recent first (#321).
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
pub recent_colors: Vec<String>,
/// Interface theme: light, dark, or follow the OS appearance (#115).
pub theme: theme::Appearance,
/// Interface size in percent (#475): scales the whole interface on top of the system's
@ -186,9 +193,11 @@ impl Default for UiState {
tab: "Home".into(),
backstage: false,
backstage_page: "home".into(),
print: print_page::PrintSettings::default(),
ribbon_collapsed: false,
recent: Vec::new(),
recent_symbols: Vec::new(),
recent_colors: Vec::new(),
theme: theme::Appearance::default(),
interface_size: theme::InterfaceSize::default(),
legacy_dark: None,
@ -244,6 +253,8 @@ pub struct WordApp {
/// Content control frames, check boxes and lists on the page.
pub controls: content_controls::ControlUi,
pub dialog: Option<dialogs::Dialog>,
/// The Colors dialog (More Colors…), shown above any other dialog (#606).
pub colors: Option<colors::ColorsDialog>,
/// The Restrict Editing pane (#412).
pub restrict: dialogs_review::RestrictPane,
pub status_msg: Option<(String, f64)>,
@ -340,6 +351,7 @@ impl WordApp {
canvas: canvas::CanvasState::default(),
controls: Default::default(),
dialog: None,
colors: None,
restrict: Default::default(),
status_msg: None,
previews: previews::Previews::default(),
@ -520,7 +532,8 @@ impl WordApp {
// Word documents are encrypted with the document's password, if it has one.
let password =
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password)?;
let opts = wordcraft_engine::cmd::file::export_options(&self.session, &params);
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
@ -721,12 +734,25 @@ impl WordApp {
}
json!({"backstage": self.ui.backstage})
}
"ui.dialog" if s("name") == Some("colors") => {
// The font colour's More Colors… (#606); `tab`: standard or custom.
let target = colors::Target::cmd("format.color").remember(colors::Remember::FontColor);
let current =
self.session.run("format.state", &json!({})).ok().and_then(|st| st.get("color").and_then(Value::as_str).and_then(colors::parse));
let mut d = colors::ColorsDialog::new(current, target);
if s("tab") == Some("custom") {
d.tab = colors::ColorsTab::Custom;
}
self.colors = Some(d);
json!({"dialog": "colors"})
}
"ui.dialog" => {
self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self);
json!({"dialog": self.dialog.as_ref().map(|d| d.name())})
}
"ui.closeDialog" => {
self.dialog = None;
self.colors = None;
json!({})
}
"ui.changePicture" => {
@ -804,10 +830,9 @@ impl WordApp {
self.pick_recipient_list();
json!({"pending": self.recipient_list_pending})
}
// Explicit submission: the browser owns its dialog; native submits the selected
// job on a worker. `file.print` opens the settings page instead.
// Native printing submits the selected queue on a worker; the browser owns its dialog.
"ui.printSettings" => return Some(printing::settings(&mut self.printing, p, self.services.printers.as_ref())),
"ui.print" | "ui.printSubmit" => return Some(self.print_to_system()),
"ui.print" | "ui.printSubmit" => return Some(self.print_to_system(p)),
"ui.discord" => {
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
json!({})
@ -825,50 +850,9 @@ impl WordApp {
}))
}
/// `ui.print`: export the document to PDF in memory and hand it to the host's print hook.
/// An error when the host can't print (File › Print then only saves a PDF).
fn print_to_system(&mut self) -> Result<Value, String> {
if self.services.print.is_none() && self.services.print_native.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
if self.services.print_native.is_some() && !self.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
// File › Options › Display: fields first, and the page colour only when asked for.
if self.ui.update_fields_before_print
&& let Err(e) = self.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {e}");
}
let without_colour = (!self.ui.print_backgrounds && self.session.doc.settings.page_color.is_some()).then(|| {
let mut d = self.session.doc.clone();
d.settings.page_color = None;
d
});
let result = wordcraft_engine::io::save_bytes("document.pdf", without_colour.as_ref().unwrap_or(&self.session.doc)).and_then(|bytes| {
let len = bytes.len();
if let Some(submit) = &self.services.print_native {
self.printing
.begin(submit(bytes, self.printing.settings.clone()))
.map_err(|_| "A print job is already being submitted.".to_owned())?;
self.print_feedback_seen = None;
Ok(len)
} else {
self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| len)
}
});
match result {
Ok(len) => {
self.status(if self.services.print_native.is_some() { tl!("Submitting print job…") } else { tl!("Opening print dialog…") });
Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()}))
}
Err(e) => {
log::error!("print failed: {e}");
let msg = i18n::fmt(tl!("Print failed: {error}"), &[("error", &e)]);
self.status(msg.clone());
Err(msg)
}
}
/// Submit the selected print job; the implementation lives beside its localized controls.
fn print_to_system(&mut self, p: &Value) -> Result<Value, String> {
printing::submit(self, p)
}
pub fn status(&mut self, s: impl Into<String>) {
@ -986,7 +970,7 @@ impl WordApp {
// interface that doesn't open the language list.
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk {
theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk);
theme::install_fonts_extra(ctx, lang.prefers_hans(), system_cjk, &self.canvas.ime_faces);
self.fonts_hans = lang.prefers_hans();
self.fonts_system_cjk = system_cjk;
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
@ -1152,6 +1136,7 @@ impl WordApp {
}
content_controls::popup(self, &ctx);
dialogs::show(self, &ctx);
colors::show(self, &ctx);
zotero::show_alert(self, &ctx);
read_aloud::show(self, &ctx);
crate::keytips::show(self, &ctx, ui);
@ -1177,11 +1162,12 @@ impl WordApp {
for (name, bytes) in files {
let lower = name.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
let list =
lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && std::mem::take(&mut self.recipient_list_pending));
// 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);
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e));
self.recipient_list_pending = asked && !list && !sheet;
let r = if sheet {
self.recipient_list_pending = false;
self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
} else if list {
self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)}))
@ -1944,6 +1930,23 @@ mod tests {
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("blocked by the browser")), "the status bar says why");
}
/// The print settings reach the hook: bad ones print nothing, `copies` repeats the pages.
#[test]
fn ui_print_honours_the_print_settings() {
let mut a = typed();
let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = sizes.clone();
a.services.print = Some(Box::new(move |bytes| {
seen.borrow_mut().push(bytes.len());
Ok(())
}));
assert!(a.run("ui.print", json!({"pages": "7-9"})).is_err(), "page 7 doesn't exist");
assert!(a.run("ui.print", json!({"range": "nope"})).is_err());
assert!(sizes.borrow().is_empty(), "nothing was sent");
a.run("ui.print", json!({"range": "current", "copies": 3})).unwrap();
assert_eq!(sizes.borrow().len(), 1);
}
/// The Print page shows the Print button only when the host can print, and the button
/// prints through `ui.print`.
#[test]
@ -2927,9 +2930,45 @@ mod tests {
inbox.lock().unwrap().push(("more.csv".into(), b"Name\nBo\nCy\n".to_vec()));
a.drain_inbox();
assert_eq!(a.session.merge.rows.len(), 2);
// A CSV picked through the picker must not turn the next .txt drop into recipients.
a.run("ui.openRecipientList", json!({})).unwrap();
inbox.lock().unwrap().push(("people.csv".into(), b"Name,City\nAda,London\n".to_vec()));
a.drain_inbox();
assert!(!a.recipient_list_pending);
inbox.lock().unwrap().push(("notes.txt".into(), b"A plain text document.\nAnother line.\n".to_vec()));
a.drain_inbox();
assert_eq!(prompt(&a), Some("saveChanges"), "the TXT opens like any document");
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay");
assert!(body_text(&a).contains("A plain text document."), "the TXT opened as a document");
// A cancelled picker leaves nothing pending once the user does something else.
a.run("ui.openRecipientList", json!({})).unwrap();
a.run("format.bold", json!({})).unwrap();
assert!(!a.recipient_list_pending);
}
/// `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.
#[test]
fn render_and_screenshot_without_a_path_reply_with_the_png() {
let ctx = egui::Context::default();
let mut a = typed();
let (req, _reply) = ControlRequest::new("ui.render", json!({"page": 1, "scale": 0.5}));
let control::Outcome::Done(v) = control::handle(&mut a, &ctx, &req) else { panic!("ui.render replies at once") };
assert_eq!(v["ok"], true, "{v}");
assert!(v["result"].get("path").is_none(), "no file: {v}");
let png = wordcraft_engine::cmd::insert::base64_decode(v["result"]["png"].as_str().expect("png")).expect("base64");
assert_eq!(&png[..4], b"\x89PNG", "{v}");
assert!(v["result"]["width"].as_u64().unwrap() > 0 && v["result"]["pages"].as_u64().unwrap() >= 1, "{v}");
let image = egui::ColorImage::from_rgba_unmultiplied([2, 1], &[255, 0, 0, 255, 0, 0, 255, 255]);
let v = control::save_screenshot(&image, None);
assert_eq!(v["ok"], true, "{v}");
assert!(v["result"].get("path").is_none(), "no file: {v}");
let png = wordcraft_engine::cmd::insert::base64_decode(v["result"]["png"].as_str().expect("png")).expect("base64");
assert_eq!(&png[..4], b"\x89PNG");
assert_eq!(v["result"]["width"], 2, "{v}");
assert_eq!(v["result"]["height"], 1, "{v}");
}
}

View File

@ -65,7 +65,7 @@ pub fn shown_id() -> egui::Id {
/// Whether the bar may be shown for the current app state.
pub fn visible(app: &WordApp) -> bool {
// Never over a modal dialog or the Backstage.
if app.dialog.is_some() || app.ui.backstage {
if app.dialog.is_some() || app.colors.is_some() || app.ui.backstage {
return false;
}
// Never while the right-click context menu is open: it owns the pointer and would double up.
@ -205,7 +205,7 @@ fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
}
}
/// Font colour: Automatic plus the standard colour grid.
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
if ui.selectable_label(false, "Automatic").clicked() {
@ -214,11 +214,8 @@ fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
return;
}
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = widgets::color_grid(ui, &theme) {
app.canvas.last_font_color = hex.clone();
let _ = app.run("format.color", json!({"color": hex}));
ui.close();
}
let target = crate::colors::Target::cmd("format.color").remember(crate::colors::Remember::FontColor);
crate::colors::menu(ui, app, &theme, target);
});
}

View File

@ -72,9 +72,31 @@ fn connector_end_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f3
}
}
/// The selected object: the selection is exactly one picture, shape or text box in the body.
/// The selected object: the selection is exactly one picture, shape or text box, in the body
/// or in a header/footer.
pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> {
wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| p.story == StoryRef::Body)
wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| selectable_story(app, p.story))
}
/// Whether objects of `story` can be selected and dragged on the canvas: the body and
/// headers/footers.
fn selectable_story(app: &WordApp, story: StoryRef) -> bool {
match story {
StoryRef::Body => true,
StoryRef::Part(id) => app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)),
}
}
/// The story whose objects the canvas handles: the header/footer being edited, else the body.
fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
let story = crate::canvas::text_box_host(app, layout).unwrap_or(app.session.sel.focus.story);
match story {
StoryRef::Body => StoryRef::Body,
StoryRef::Part(id) if selectable_story(app, StoryRef::Part(id)) => StoryRef::Part(id),
// Notes and text-box text: no top-level objects live here, so grabs find nothing and
// the press falls through to text handling.
other => other,
}
}
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
@ -151,7 +173,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
return true;
}
let multi = resp.double_clicked() || resp.triple_clicked();
if !(pressed || multi) || crate::canvas::editing_header_footer(app, layout) {
if !(pressed || multi) {
return false;
}
// A selected connector's end: drag it to glue it.
@ -176,7 +198,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
None => return true,
};
// Objects in table cells can be selected and resized, not moved; charts neither.
if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) {
if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) {
app.canvas.obj_drag =
Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture, end: None });
}
@ -221,9 +243,11 @@ pub fn select_at(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f
true
}
/// What a press at `at` would grab: a handle of the shown frame, else an object.
/// What a press at `at` would grab: a handle of the shown frame, else an object of the story
/// being edited (body, or the header/footer).
fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> {
let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body && !fixed(app, o)).and_then(|(o, _)| {
let story = active_story(app, layout);
let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| {
let f = screen(pages, scale, o.page, o.rect)?;
if frame::rotate_handle_at(f, o.spin.deg, at) {
return Some((o, Grab::Rotate));
@ -232,14 +256,16 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
});
handle.or_else(|| {
let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?;
// Selectable: body objects (table cells too); notes' objects aren't yet.
layout.object_at(page, x, y, EDGE / scale.max(0.01)).filter(|o| o.story == StoryRef::Body).map(|o| (o, Grab::Move))
// Selectable: body objects (table cells too) or the header/footer's own objects;
// notes' objects aren't yet.
layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move))
})
}
/// Objects that can be dragged to a new place: those in the body's own paragraphs.
fn movable(o: &ObjectHit) -> bool {
o.story == StoryRef::Body && o.path.depth() == 0
/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a
/// header/footer.
fn movable(app: &WordApp, o: &ObjectHit) -> bool {
o.path.depth() == 0 && selectable_story(app, o.story)
}
/// Commit a finished drag as one command.
@ -255,7 +281,9 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) {
}
Grab::Move => json!({"page": d.drag.to_page, "x": r.x, "y": r.y}),
// An inline object stays in the text; a floating one keeps its far edges put.
Grab::Resize(_) if d.object.floating() && movable(&d.object) => json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y}),
Grab::Resize(_) if d.object.floating() && movable(app, &d.object) => {
json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y})
}
Grab::Resize(_) => json!({"width": r.w, "height": r.h}),
};
let _ = app.run("arrange.bounds", params);
@ -317,9 +345,9 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
if let Some((o, editing)) = active(app, layout)
&& let Some(f) = screen(pages, scale, o.page, o.rect)
{
// Handles where it can be resized and turned (body objects, not charts): else a plain
// frame.
let handles = o.story == StoryRef::Body && !fixed(app, &o);
// Handles where it can be resized and turned (body and header/footer objects,
// not charts): else a plain frame.
let handles = selectable_story(app, o.story) && !fixed(app, &o);
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles);
}
}
@ -369,9 +397,6 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
if let Some(d) = &app.canvas.obj_drag {
return Some(if d.end.is_some() { egui::CursorIcon::Crosshair } else { d.drag.grab.cursor() });
}
if crate::canvas::editing_header_footer(app, layout) {
return None;
}
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}

View File

@ -410,12 +410,19 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
}
}
/// Style sets shown in the Design tab's gallery, in order.
const STYLE_SETS: [&str; 7] = ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"];
/// Width of one gallery thumbnail, points.
const STYLE_SET_W: f32 = 56.0;
/// Width the whole gallery takes with the ribbon's 1 pt item spacing.
pub const STYLE_SET_GALLERY_W: f32 = STYLE_SETS.len() as f32 * (STYLE_SET_W + 1.0) - 1.0;
/// Design tab: style set tiles.
pub fn style_set_gallery(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
for name in ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"] {
let (r, resp) = ui.allocate_exact_size(vec2(56.0, crate::widgets::CONTENT_H), Sense::click());
for name in STYLE_SETS {
let (r, resp) = ui.allocate_exact_size(vec2(STYLE_SET_W, crate::widgets::CONTENT_H), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}

View File

@ -0,0 +1,255 @@
//! File › Print (#641): the print settings and a preview of the pages.
//!
//! Page range and parity shape the PDF. Native printer settings and copy collation go to
//! the shared spooler; browser printing and saved print PDFs retain copies in the PDF.
use std::sync::Arc;
use egui::{Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
use serde_json::{Value, json};
use wordcraft_layout::DocLayout;
use crate::theme::{Tokens, medium, regular};
use crate::{WordApp, icons};
/// The Print page's settings. Not saved with the document or the UI state.
#[derive(Clone, Debug, PartialEq)]
pub struct PrintSettings {
/// `all`, `current`, `selection` or `custom`.
pub range: String,
/// The custom range text, like `1-3, 5`.
pub pages: String,
/// `all`, `odd` or `even`.
pub parity: String,
pub copies: u32,
pub collate: bool,
/// The page shown in the preview (0-based).
pub preview: usize,
}
impl Default for PrintSettings {
fn default() -> Self {
PrintSettings { range: "all".into(), pages: String::new(), parity: "all".into(), copies: 1, collate: true, preview: 0 }
}
}
impl PrintSettings {
/// The params of `ui.print` and `file.printPdf` for these settings.
pub fn params(&self) -> Value {
let mut v = json!({"range": self.range, "parity": self.parity, "copies": self.copies, "collate": self.collate});
if self.range == "custom" {
v["pages"] = json!(self.pages);
}
v
}
}
const RANGES: [(&str, &str); 4] = [("all", "All pages"), ("current", "Current page"), ("selection", "Selected pages"), ("custom", "Custom range")];
const PARITIES: [(&str, &str); 3] = [("all", "All pages in the range"), ("odd", "Odd pages only"), ("even", "Even pages only")];
/// A choice list over `(id, label)` pairs; true when the choice changed.
fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)]) -> bool {
let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l);
let mut changed = false;
egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| {
for (id, label) in items {
if ui.selectable_label(current == id, tl!(label)).clicked() && current != id {
*current = (*id).to_string();
changed = true;
}
}
});
changed
}
/// The output layout, kept while the document is unchanged (laying out every frame would be slow).
fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc<DocLayout> {
let id = egui::Id::new("print_page_layout");
let rev = app.session.rev();
if let Some((r, l)) = ctx.data(|d| d.get_temp::<(u64, Arc<DocLayout>)>(id))
&& r == rev
{
return l;
}
let l = app.session.export_layout();
ctx.data_mut(|d| d.insert_temp(id, (rev, l.clone())));
l
}
/// The texture of one page, re-rendered only when the document, page or size changes.
fn page_texture(app: &WordApp, ctx: &egui::Context, lay: &DocLayout, page: usize, width: f32) -> Option<TextureHandle> {
let id = egui::Id::new("print_page_texture");
let key = (app.session.rev(), page, width as u32, (ctx.pixels_per_point() * 100.0) as u32);
if let Some((k, t)) = ctx.data(|d| d.get_temp::<((u64, usize, u32, u32), TextureHandle)>(id))
&& k == key
{
return Some(t);
}
let pg = lay.pages.get(page)?;
let scale = (width / pg.w.max(1.0) * ctx.pixels_per_point()).clamp(0.05, 4.0);
let img = wordcraft_render::render_page(&app.session.doc, pg, scale, &Default::default());
let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
let tex = ctx.load_texture("print_page_preview", color, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
Some(tex)
}
/// The Print page: settings on the left, the preview on the right.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let lay = layout_of(app, &ctx);
let total = lay.pages.len();
let current = app.session.current_pages(&lay);
app.ui.print.preview = app.ui.print.preview.min(total.saturating_sub(1));
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(380.0);
ui.spacing_mut().item_spacing.y = 8.0;
let native = app.services.print_native.is_some();
let hook = native || app.services.print.is_some();
if native {
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
}
app.ui.print.copies = app.printing.settings.copies;
} else if hook {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
ui.label(
egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim),
);
} else {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
}
ui.add_space(4.0);
if hook
&& ui
.add_enabled(
!native || app.printing.ready(),
egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)),
)
.clicked()
{
let params = app.ui.print.params();
let _ = app.run("ui.print", params);
}
if ui
.add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0)))
.clicked()
{
app.print_pdf_dialog();
}
ui.add_space(6.0);
if native {
if app.ui.print.copies > 1 {
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
}
} else {
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2));
app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES);
if app.ui.print.copies > 1 {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
}
});
}
ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) {
// Show the first page that will print.
app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) };
}
if app.ui.print.range == "custom" {
ui.add(
egui::TextEdit::singleline(&mut app.ui.print.pages)
.hint_text(tl!("Pages like 1-3, 5"))
.desired_width(260.0)
.char_limit(wordcraft_engine::print::MAX_SPEC_CHARS),
);
}
choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES);
match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) {
Ok(plan) => {
let msg = crate::i18n::fmt(
tl!("{count} of {total} pages will print"),
&[("count", &plan.chosen.to_string()), ("total", &total.to_string())],
);
ui.label(egui::RichText::new(msg).color(t.text_dim));
}
Err(e) => {
ui.label(egui::RichText::new(e.to_string()).color(t.red));
}
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
if hook {
ui.label(tl!("Or save a copy in another format:"));
}
export_formats(app, ui);
});
ui.add_space(24.0);
ui.vertical(|ui| preview(app, ui, &lay, total));
});
}
/// The preview: one page with Previous/Next.
fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let width = 340.0;
let page = app.ui.print.preview;
let aspect = lay.pages.get(page).map_or(11.0 / 8.5, |p| p.h.max(1.0) / p.w.max(1.0)).clamp(0.2, 5.0);
let (r, _) = ui.allocate_exact_size(vec2(width, width * aspect), Sense::hover());
ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow);
ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
if let Some(tex) = page_texture(app, &ctx, lay, page, width) {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| {
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
let col = if enabled { t.icon } else { t.text_disabled };
if enabled && resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(4.0), icon, col, col);
enabled && resp.on_hover_text(tip).clicked()
};
if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) {
app.ui.print.preview = page - 1;
}
let label = crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &(page + 1).to_string()), ("pages", &total.to_string())]);
ui.label(egui::RichText::new(label).font(regular(13.0)));
if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) {
app.ui.print.preview = page + 1;
}
});
}
/// The "save a copy" formats (File › Export, and below the settings on the Print page).
pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
("Word document (*.docx)", "docx"),
("Word XML Document (*.xml)", "xml"),
("OpenDocument Text (*.odt)", "odt"),
("Rich Text Format (*.rtf)", "rtf"),
("Web page (*.html)", "html"),
("Markdown (*.md)", "md"),
("LaTeX (*.tex)", "tex"),
("Plain text (*.txt)", "txt"),
("Page image (*.png)", "png"),
] {
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
app.export_dialog(ext);
}
ui.add_space(4.0);
}
}

View File

@ -1,6 +1,9 @@
//! Localized print-page wording around the shared, backend-independent controls.
use craft_ui::printing::Labels;
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit};
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State};
/// Native submission adds WordCraft's PDF-page collation setting to the shared spooler settings.
pub type Submit = Box<dyn Fn(Vec<u8>, Settings, bool) -> std::sync::mpsc::Receiver<Result<String, String>>>;
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
let labels = Labels {
@ -96,3 +99,62 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
}
}
/// Build the upstream page-range/markup PDF and hand it to the chosen host print flow.
pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result<serde_json::Value, String> {
use serde_json::json;
if app.services.print.is_none() && app.services.print_native.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
let native = app.services.print_native.is_some();
if native && !app.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let mut pdf_params = params.clone();
let mut settings = app.printing.settings.clone();
let collate = params.get("collate").map(|value| value.as_bool().ok_or("collate must be a boolean")).transpose()?.unwrap_or(app.ui.print.collate);
if native {
let object = pdf_params.as_object_mut().ok_or("print settings must be an object")?;
if let Some(copies) = object.get("copies") {
settings.copies = copies.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")? as u32;
}
// Copies/collation belong to the spooler. Baking them into the PDF would multiply
// the copies twice, and duplex could put the end of one copy behind the next.
object.insert("copies".into(), json!(1));
}
if app.ui.update_fields_before_print
&& let Err(error) = app.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {error}");
}
let colour = if app.ui.print_backgrounds { None } else { app.session.doc.settings.page_color.take() };
let pdf = app.session.print_pdf(&pdf_params).map_err(|error| error.to_string());
if colour.is_some() {
app.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, plan)| {
if native && plan.chosen.checked_mul(settings.copies as usize).is_none_or(|pages| pages > wordcraft_engine::print::MAX_PRINT_PAGES) {
return Err("the selected pages and copies exceed the print-job page limit".into());
}
let len = bytes.len();
if let Some(submit) = &app.services.print_native {
app.printing.begin(submit(bytes, settings, collate)).map_err(|_| "A print job is already being submitted.".to_owned())?;
app.print_feedback_seen = None;
} else if let Some(print) = &app.services.print {
print(&bytes)?;
}
Ok(len)
});
match result {
Ok(len) => {
app.status(if native { tl!("Submitting print job…") } else { tl!("Opening print dialog…") });
Ok(json!({"printing": true, "bytes": len, "pending": native}))
}
Err(error) => {
log::error!("print failed: {error}");
let msg = crate::i18n::fmt(tl!("Print failed: {error}"), &[("error", &error)]);
app.status(msg.clone());
Err(msg)
}
}
}

View File

@ -3,8 +3,9 @@
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::colors::{Remember, Target};
use crate::theme::{Tokens, medium, regular, semibold};
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, color_grid, combo, font_combo, group, menu_button, small, split};
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split};
use crate::{WordApp, icons};
pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS;
@ -505,11 +506,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| {
mi(ui, app, "Automatic", "format.color", json!({"color": "auto"}));
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
app.canvas.last_font_color = hex.clone();
let _ = app.run("format.color", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("format.color").remember(Remember::FontColor));
});
});
});
@ -598,11 +595,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "para.shading", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
app.canvas.last_shading = hex.clone();
let _ = app.run("para.shading", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("para.shading").remember(Remember::Shading));
});
split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| {
for (l, k) in [
@ -840,15 +833,11 @@ fn draw(app: &mut WordApp, ui: &mut Ui) {
let sw = Some(crate::theme::c32(set.color));
split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
// With ink lasso-selected the colour goes to the selection, else to the pen.
if lasso_on {
let _ = app.run("draw.lassoColor", json!({"color": hex}));
} else {
let _ = app.run(pen_command(tool), json!({"color": hex}));
update_gallery_pen(app, custom, tool);
}
ui.close();
// With ink lasso-selected the colour goes to the selection, else to the pen.
let cmd = if lasso_on { "draw.lassoColor" } else { pen_command(tool) };
crate::colors::menu(ui, app, &theme, Target::cmd(cmd));
if !lasso_on {
update_gallery_pen(app, custom, tool);
}
});
menu_button(ui, app, "thickness", None, "Thickness", false, |ui, app| {
@ -932,6 +921,20 @@ fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::
});
}
/// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped
/// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by,
/// the thumbnails (#638). Returns the box and the space the thumbnails used.
fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) {
ui.add_space(4.0);
let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover());
let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min)));
gui.set_clip_rect(gallery.intersect(ui.clip_rect()));
gui.spacing_mut().item_spacing = vec2(1.0, 1.0);
crate::previews::style_set_gallery(app, &mut gui);
ui.add_space(8.0);
(gallery, gui.min_rect())
}
fn design(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Themes", None, app, |ui, app| {
menu_button(ui, app, "themes", Some("Themes"), "Themes", true, |ui, app| {
@ -941,7 +944,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Document Formatting", None, app, |ui, app| {
crate::previews::style_set_gallery(app, ui);
style_set_box(app, ui);
stack(ui, |ui| {
menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, |ui, app| {
for (name, ..) in wordcraft_engine::cmd::design::THEMES {
@ -984,17 +987,14 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, |ui, app| {
mi(ui, app, "No Color", "design.pageColor", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("design.pageColor", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor"));
});
big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false);
});
}
fn layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| {
group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| {
// Text Direction works on what the caret is in: table cells, a text box, else the section.
let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session);
menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, |ui, app| {
@ -1501,6 +1501,10 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() {
let _ = app.run("view.navigationPane", json!({"value": n}));
}
let mut w = v.hide_white_space;
if ui.checkbox(&mut w, tl!("Hide White Space")).changed() {
let _ = app.run("view.hideWhiteSpace", json!({"value": w}));
}
});
});
group(ui, "Zoom", None, app, |ui, app| {
@ -1733,17 +1737,11 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.fill", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill"));
});
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.outline", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline"));
ui.separator();
ui.menu_button(tl!("Weight"), |ui| {
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
@ -1875,10 +1873,8 @@ fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rg
}
ui.separator();
ui.menu_button(tl!("Glow Colors"), |ui| {
if let Some(hex) = color_grid(ui, theme) {
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
ui.close();
}
let target = Target::at("shape.effects", json!({"glow": {"color": null, "size": glow_size.unwrap_or(8.0)}}), "/glow/color");
crate::colors::menu(ui, app, theme, target);
});
});
ui.menu_button(tl!("Soft Edges"), |ui| {
@ -1913,10 +1909,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
});
menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("picture.iconColor", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor"));
});
menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| {
for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] {
@ -2102,11 +2095,7 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
app.canvas.last_shading = hex.clone();
let _ = app.run("table.shading", json!({"color": hex}));
ui.close();
}
crate::colors::menu(ui, app, &theme, Target::cmd("table.shading").remember(Remember::Shading));
});
});
});
@ -2137,14 +2126,27 @@ fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) {
let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default();
let stored_w = t.grid.get(t.grid_col(r, c)).copied();
let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st } if *table == tp && *row == r && *cell == c && *st == story => {
wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *row == r && *cell == c && *st == story => {
Some(*rect)
}
_ => None,
});
// The row's own height: a vertically merged cell's box spans several rows, so take the
// shortest box in this row.
let row_h = app
.session
.layout()
.pages
.iter()
.flat_map(|pg| pg.items.iter())
.filter_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, table, row, story: st, .. } if *table == tp && *row == r && *st == story => Some(rect.h),
_ => None,
})
.reduce(f32::min);
let unit = crate::options::unit();
let k = unit.pt_per_unit();
let h0 = row.height.or(laid.map(|x| x.h)).unwrap_or(0.0);
let h0 = row.height.or(row_h).unwrap_or(0.0);
let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0);
let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact;
// The edited value in points, and whether it continues a drag (joins the previous Undo step).
@ -2269,6 +2271,26 @@ mod tests {
wordcraft_engine::cmd::insert::base64_encode(&b)
}
/// The style set gallery fills exactly its own box and the next control starts after a gap,
/// so Colors and Fonts can't overlap the thumbnails (#638).
#[test]
fn design_style_set_gallery_stays_in_its_box() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut seen = None;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 200.0)).build_ui(|ui| {
ui.horizontal_top(|ui| {
let (gallery, used) = style_set_box(&mut app, ui);
seen = Some((gallery, used, ui.cursor().min.x));
});
});
h.run_steps(2);
drop(h);
let (gallery, used, next_x) = seen.unwrap();
assert!((gallery.width() - crate::previews::STYLE_SET_GALLERY_W).abs() < 0.01);
assert!(used.right() <= gallery.right() + 0.01, "{used:?} {gallery:?}");
assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}");
}
#[test]
fn picture_format_tab_appears_when_picture_selected() {
let mut s = Session::new(wordcraft_doc::Document::new());
@ -2293,6 +2315,21 @@ mod tests {
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
}
#[test]
fn page_setup_launcher_opens_the_dialog() {
// A group launcher runs its command with no parameters (#471).
let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.run("layout.pageSetup", json!({})).unwrap();
assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::PageSetup { .. })));
// `ui.dialog` needs a dialog name, so it can't be a launcher.
let launchers: Vec<&str> = include_str!("ribbon.rs")
.split("group(ui, \"")
.filter_map(|g| g.split_once('"')?.1.strip_prefix(", Some(\"")?.split('"').next())
.collect();
assert!(launchers.contains(&"layout.pageSetup"), "{launchers:?}");
assert!(!launchers.contains(&"ui.dialog"), "{launchers:?}");
}
#[test]
fn smart_art_picker_inserts_and_design_tab_follows_the_selection() {
let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default());

View File

@ -275,7 +275,52 @@ pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) {
/// a glyph (#241, #486). Reading that font is a large file read, so callers ask for it only when
/// CJK text is on screen: a CJK interface language, or the language names in Options.
pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) {
ctx.set_fonts(font_definitions(prefer_hans, system_cjk));
install_fonts_extra(ctx, prefer_hans, system_cjk, &[]);
}
/// [`install_fonts_with`], also adding document-font `faces` as last-resort fallbacks (#565): the
/// faces that cover the text an input method is composing.
pub fn install_fonts_extra(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool, faces: &[Arc<wordcraft_fonts::FontFace>]) {
let mut fonts = font_definitions(prefer_hans, system_cjk);
add_fallback_faces(&mut fonts, faces);
ctx.set_fonts(fonts);
}
/// Add `faces` after every other font of the interface families.
pub fn add_fallback_faces(fonts: &mut FontDefinitions, faces: &[Arc<wordcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
fonts.font_data.insert(name.clone(), Arc::new(data));
for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
if let Some(v) = fonts.families.get_mut(&fam) {
v.push(name.clone());
}
}
}
}
/// Document faces needed to draw `text` (the input method's composition), one for each character
/// the interface fonts cannot draw, skipping faces in `have` (#565). Characters of the embedded
/// interface fonts are not looked up; very large faces (system CJK collections) are left to the CJK
/// fallback.
pub fn faces_for_preedit(
text: &str,
have: &[Arc<wordcraft_fonts::FontFace>],
covered: impl Fn(char) -> bool,
lookup: impl Fn(char) -> Option<Arc<wordcraft_fonts::FontFace>>,
) -> Vec<Arc<wordcraft_fonts::FontFace>> {
let mut out: Vec<Arc<wordcraft_fonts::FontFace>> = Vec::new();
for c in text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !covered(*c)) {
if have.iter().chain(&out).any(|f| f.covers(c)) {
continue;
}
if let Some(f) = lookup(c).filter(|f| f.data().len() <= 16 << 20 && !have.iter().chain(&out).any(|h| h.id() == f.id())) {
out.push(f);
}
}
out
}
/// The interface fonts; see [`install_fonts_with`].
@ -510,6 +555,26 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
mod tests {
use std::collections::BTreeSet;
/// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the
/// interface fonts were used. Faces found for the uncovered characters are added as fallbacks,
/// once, and not for characters the interface fonts draw.
#[test]
fn preedit_gets_document_fallback_faces() {
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
let lookup = |_: char| Some(face.clone());
let bangla = "\u{0986}\u{09AE}\u{09BF}";
let got = super::faces_for_preedit(bangla, &[], |_| false, lookup);
assert_eq!(got.len(), 1, "one face, not one per character");
assert!(super::faces_for_preedit(bangla, &got, |_| false, lookup).is_empty(), "already added");
assert!(super::faces_for_preedit(bangla, &[], |_| true, lookup).is_empty(), "the interface fonts draw it");
assert!(super::faces_for_preedit("abc", &[], |_| false, lookup).is_empty(), "ASCII never needs one");
let mut defs = super::font_definitions(false, false);
let before = defs.font_data.len();
super::add_fallback_faces(&mut defs, &got);
assert_eq!(defs.font_data.len(), before + 1);
assert!(defs.families[&egui::FontFamily::Proportional].last().is_some_and(|n| n.starts_with("fallback ")));
}
/// Symbols written as text in the interface code must exist in the interface fonts (Inter and
/// egui's emoji fallbacks); a missing glyph draws as an empty box, as the panes' "✕" close
/// button once did. Letters are the catalogs' business (see the i18n font tests).

View File

@ -399,7 +399,7 @@ pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
picked
}
fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
pub(crate) fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
let t = Tokens::get(ui.ctx());

View File

@ -38,6 +38,8 @@ pub struct ZoteroLink {
alert: Option<Alert>,
/// The command in flight.
pub busy: Option<Command>,
/// The document the command in flight started in; its calls are refused once another is open.
document: u64,
/// How the last command ended.
pub last: Option<Value>,
/// Zotero's calls in the command in flight (logged when it ends).
@ -96,6 +98,7 @@ fn start(app: &mut WordApp, cmd: Command, ctx: Option<egui::Context>) -> Result<
if let Some(b) = app.zotero.busy {
return Err(format!("Zotero is still working on {}; finish in Zotero's window first", b.wire_name()));
}
let document = app.session.document_id();
let (call_tx, call_rx) = channel::<Pending>();
let (done_tx, done_rx) = channel();
let wake = ctx.clone();
@ -123,6 +126,7 @@ fn start(app: &mut WordApp, cmd: Command, ctx: Option<egui::Context>) -> Result<
z.calls = Some(call_rx);
z.done = Some(done_rx);
z.busy = Some(cmd);
z.document = document;
log::info!("Zotero: {} started", cmd.wire_name());
app.status(tl!("Waiting for Zotero…"));
Ok(json!({"started": cmd.wire_name()}))
@ -156,7 +160,7 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
// One alert at a time: Zotero sends nothing else while it waits for the answer.
while app.zotero.alert.is_none() {
let Some(p) = app.zotero.calls.as_ref().and_then(|rx| rx.try_recv().ok()) else { break };
if p.call.method == "Document_displayAlert" {
if p.call.method == "Document_displayAlert" && app.session.document_id() == app.zotero.document {
let text = p.call.args.get(1).and_then(Value::as_str).unwrap_or("").to_string();
let buttons = p.call.args.get(3).and_then(Value::as_i64).unwrap_or(0);
app.zotero.methods.push(p.call.method.clone());
@ -164,6 +168,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
continue;
}
app.zotero.methods.push(p.call.method.clone());
if app.session.document_id() != app.zotero.document {
// New or Open replaced the document this command started in: its edits must not land in the new one.
let _ = p.reply.send(Err("WordCraft replaced the document this command started in".into()));
continue;
}
let rev = app.session.rev();
let mut host = AppHost { ctx };
let r = app.zotero.bridge.handle(&mut app.session, &mut host, &p.call);
@ -313,6 +322,27 @@ mod tests {
assert_eq!(app.session.doc.plain_text(StoryRef::Body), "Cite: ");
}
#[test]
fn a_pending_citation_does_not_insert_into_a_replacement_document() {
let (addr, server) = fake_zotero(vec![
json!(["Application_getActiveDocument", [3]]),
json!(["Document_insertField", [1, "ReferenceMark", 0]]),
json!(["Field_setCode", [1, 1, "ITEM CSL_CITATION {}"]]),
json!(["Field_setText", [1, 1, "(Sample, 2020)", false]]),
json!(["Document_complete", [1]]),
]);
let mut app = WordApp::new(Session::new(Document::new()), Services::default());
app.zotero.options.addr = addr;
let ctx = egui::Context::default();
app.ctx = Some(ctx.clone());
app.run("ui.zotero.addEditCitation", json!({})).unwrap();
app.run("file.new", json!({"template": "blank"})).unwrap();
pump(&mut app, &ctx, |a| a.zotero.busy.is_none());
server.join().unwrap();
assert_eq!(app.session.doc.plain_text(StoryRef::Body), "");
assert!(!app.session.can_undo(), "the old command left no undo step in the new document");
}
#[test]
fn no_zotero_is_a_clear_error() {
let free = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();

View File

@ -11,8 +11,8 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() {
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings| {
observed.borrow_mut().push((bytes, settings));
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
@ -33,6 +33,7 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() {
assert_eq!(jobs[0].1.copies, 3);
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
assert!(jobs[0].1.grayscale);
assert!(jobs[0].2, "copies are collated by default");
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.status_msg.as_ref().unwrap().0.contains("printer is offline"));
@ -48,7 +49,7 @@ fn print_settings_are_visible_in_a_narrow_window() {
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _| {
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
@ -99,7 +100,7 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _| {
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("same receipt".into())).unwrap();
receiver
@ -116,3 +117,37 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() {
assert!(app.status_msg.is_none());
}
}
#[test]
fn native_ranges_and_parity_do_not_duplicate_spooler_copies() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
for page in 1..=3 {
app.run("text.insert", json!({"text":format!("Distinct page {page}")})).unwrap();
if page < 3 {
app.run("insert.pageBreak", json!({})).unwrap();
}
}
assert_eq!(app.session.export_layout().pages.len(), 3);
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
for bad in [json!({"pages":"8"}), json!({"parity":"bad"}), json!({"copies":0}), json!({"collate":"yes"})] {
assert!(app.run("ui.printSubmit", bad).is_err());
}
assert!(jobs.borrow().is_empty(), "invalid settings do not send any job");
let params = json!({"pages":"1-3","parity":"odd","collate":false});
let (expected, plan) = app.session.print_pdf(&params).unwrap();
assert_eq!(plan.pages, vec![0, 2]);
app.run("ui.printSubmit", params).unwrap();
let jobs = jobs.borrow();
assert_eq!(jobs[0].0, expected, "the PDF contains only the two selected pages, once each");
assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF");
assert!(!jobs[0].2, "uncollated copies reach the spooler");
}

View File

@ -568,6 +568,10 @@ fn remove_empty_note(s: &mut Session, f: &ZField) -> Option<Pos> {
for b in blocks {
if let Some(p) = b.as_para() {
rest.push_str(&p.text);
// A picture, shape, equation... is content too, even though it has no text.
if !p.objects.iter().all(|o| o.is_marker() || matches!(o, InlineObject::NoteRef { .. })) {
return None;
}
}
}
if rest.chars().any(|c| c != OBJ && !c.is_whitespace()) {

View File

@ -512,6 +512,22 @@ fn a_note_with_other_text_stays_when_its_citation_leaves() {
assert!(z.call("Document_convert", json!([1, [999], [], [1], 1])).is_err());
}
#[test]
fn a_note_with_a_picture_stays_when_its_citation_leaves() {
let mut z = Z::new("Text.", 5);
z.begin();
let a = z.cite(1, "Doe, Title.");
let pid = *z.s.doc.parts.keys().next().unwrap();
let p = z.s.doc.para_mut(StoryRef::Part(pid), &Path::top(0)).unwrap();
let n = p.len();
let image = InlineObject::Image { media: "m".into(), w: 1.0, h: 1.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
p.insert_object(n, image, &Default::default()).unwrap();
z.ok("Field_convert", json!([1, a, "ReferenceMark", 0]));
assert_eq!(z.s.doc.parts.len(), 1);
assert!(z.s.doc.para(StoryRef::Part(pid), &Path::top(0)).unwrap().objects.iter().any(|o| matches!(o, InlineObject::Image { .. })));
assert!(z.text().ends_with("Doe, Title."));
}
/// Zotero reads a new bibliography field back before filling it, and unlinks it when it is
/// empty (it takes that for a bibliography the user deleted). Seen with Zotero 10: Add/Edit
/// Bibliography did nothing.

View File

@ -2,7 +2,7 @@ ArtCraft brand assets: license
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
and are NOT open source. They are not covered by DesignCraft's MIT OR Apache-2.0 license, and nothing in
and are NOT open source. They are not covered by WordCraft's MIT OR Apache-2.0 license, and nothing in
that license, in NOTICE or in any other file of this repository grants any right to them.
Permission
@ -11,7 +11,7 @@ The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable,
permission to use the ArtCraft Marks only:
1. as part of copies of this repository; and
2. within DesignCraft itself, its documentation and its official builds, to identify DesignCraft as made
2. within WordCraft itself, its documentation and its official builds, to identify WordCraft as made
by the ArtCraft team and community and to link to getartcraft.com and its community.
Conditions
@ -21,17 +21,17 @@ The permission holds only while every one of these is true:
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
them with any other name, mark or artwork.
- They are used only in the context of DesignCraft, and only to say that the ArtCraft team makes it.
- They are used only in the context of WordCraft, and only to say that the ArtCraft team makes it.
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
any other product, service, fork, modified version, plug-in, organization or person.
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
handle, app-store listing, advertising or marketing of anything other than DesignCraft.
handle, app-store listing, advertising or marketing of anything other than WordCraft.
Forks and modified versions
If you distribute or publish a modified version of DesignCraft, or any work derived from it, you must
If you distribute or publish a modified version of WordCraft, or any work derived from it, you must
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
present it as an ArtCraft product. You may say, in plain text, that your work is based on DesignCraft by
present it as an ArtCraft product. You may say, in plain text, that your work is based on WordCraft by
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
unchanged.

View File

@ -60,8 +60,8 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
| `ui.press` / `ui.release` | `x`,`y`, modifiers | primary button down / up there (with `ui.move` between: a drag you can screenshot halfway) |
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
| `ui.text` | `text` | typed text through egui |
| `ui.screenshot` | `path?` | PNG of the window |
| `ui.render` | `path`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine |
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
| `ui.parity` | — | feature-catalog parity |
| `ui.resize` / `ui.focus` / `app.quit` | | window control |
@ -79,3 +79,9 @@ false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `dupl
rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current
print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false
and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate.
`ui.printSubmit` / `ui.print` also accept the upstream PDF page settings (`range`, `pages`,
`parity`, `copies`, `collate`). Native printing renders the selected pages once and passes
copies/collation to the spooler; the browser and `file.printPdf` keep copies in the PDF.
Omitting native `copies` uses the configured `ui.printSettings` value. `collate` defaults to
File › Print's setting; invalid ranges submit no job.

View File

@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; 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-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; 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;
@ -23,11 +23,11 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. 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). No PDF/A option, no "best for printing/online" choice |
| 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. Thin: comments, notes, revisions, sections, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection; Word's encoding choice dialog and line-ending options missing |
| `.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 |
| `.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 |
@ -60,10 +60,10 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) |
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) |
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
@ -77,6 +77,27 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| `w:eastAsianLayout`, `w:fitText` | ❌ | ❌ | East Asian layout |
| Glossary document (building blocks), `w:altChunk`, sub-documents | ❌ | ❌ | |
| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard |
| Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written |
## Paragraph-default preservation
The styles reader records whether `w:pPrDefault` exists. The writer keeps an omitted
container omitted when its modeled properties are still empty. An explicitly empty
container stays present, and editing document-default spacing writes the new values.
New documents and older serialized style sheets keep their explicit defaults.
`crates/docx/tests/fixtures.rs` covers omitted `docDefaults`, omitted paragraph defaults,
empty containers and populated defaults across two save/reopen cycles. This preserves
application-defined defaults without assigning inferred Word spacing values to the model.
Observed in Word for Mac 16.113.3 (26092714): three synthetic paragraph-default
controls (omitted, explicitly empty, populated) and two reduced invoice controls retain
identical text, glyph/image geometry, page sizes and embedded font-program hashes through
two WordCraft save/reopen cycles. All fifteen variants open without repair warnings.
Word PDFs and measurements remain local under ignored `plan/word/`; they are not fixtures.
These checks cover preservation on Word reopen, not WordCraft pagination parity.
The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.paragraphpropertiesdefault?view=openxml-3.0.1)
describes omission as leaving paragraph defaults application-defined. Real-world corpus
coverage and WordCraft layout matching remain separate gaps.
## What decides beta for formats
@ -97,8 +118,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) |
| 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) |
| 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) |
| 2026-10-11 | trivial | Print Markup: comments exported in PDFs, prints, page images and HTML/Markdown/text copies (#589) |
| 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) |
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
| 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML |
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
| 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) |
| 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) |
@ -113,6 +137,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
| 2026-10-11 | trivial | Section text direction read and written (#491) |
| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded |

View File

@ -13,12 +13,12 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| # | 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; "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) |
| 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-default 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 | **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) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), 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 | 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, duplex and grayscale now submit through craft-print; page ranges and driver tray/options UI 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) |
| 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) |
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
@ -67,7 +67,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 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. |
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
| 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list |
| 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains |
@ -92,6 +92,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth |
| 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-11 | trivial | #12 narrowed: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395) |
| 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list |
| 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list |

View File

@ -10,7 +10,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours |
|---|---|---|---|---|---|---|---|
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, duplex and grayscale | 🟡 Direct spooler printing; printer, copies, duplex and grayscale | 🟡 Direct spooler printing; printer, copies, duplex and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 |
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316); startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) |
@ -23,7 +23,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. The upstream preview, page ranges, parity and Print Markup remain available. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) |
| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) |
| 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |

View File

@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (East Asian list numbers, mixed-font list labels, #534; 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-11 · **Change:** 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,
@ -20,6 +20,7 @@ font can't match Word's metrics.
|---|---|---|---|
| Line breaking (UAX #14 with Word's exceptions: no break after `/`) | ✅ | `layout::tests::no_line_break_right_after_a_slash` | — |
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — |
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests_complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
| 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 |
@ -79,6 +80,7 @@ font can't match Word's metrics.
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |
| 2026-10-11 | trivial | Mirror indents (`w:mirrorIndents`): left/right indents swap on even pages; suppress line numbers inherited from styles (#512) |
| 2026-10-11 | trivial | NUMPAGES/SECTIONPAGES in the body show the real totals (the body is laid out again once the page count is known, at most three extra passes, #451); footnotes referenced in table cells go to the page bottom (#450); PDF links to a bookmark go to the page its start is on, not its paragraph's first page (#455) |
| 2026-10-11 | minor | Complex scripts: script itemisation, OpenType fallback fonts, syllable clusters, Myanmar syllable breaks (#604) |
| 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) |
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) |
| 2026-10-11 | trivial | Document grid: line and character pitch (#391) |

View File

@ -2,11 +2,11 @@
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.
**481 of 496 catalog features have commands (97.0%).**
**482 of 497 catalog features have commands (97.0%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| File | 21 | 21 |
| Quick Access | 4 | 4 |
| Home | 102 | 103 |
| Insert | 47 | 53 |

View File

@ -12,7 +12,8 @@ Opus 5.5 agent wall-clock.
(Clipboard pane #264, Manage Styles #270, Group #267), fix field bugs (Hebrew RTL #20, IME
fixes #155–#164) or add languages (de, nb, nn, he). Owner review needed.
2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files,
open/render/round-trip them, compare with Word locally, fix what breaks.
open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty
paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open.
3. **Preserve what we can't render** (gap #2, 10–15 h): charts, SmartArt, OLE, ink, group shapes
survive a round trip and show their fallback pictures.
4. **Stability sweep** (gap #3, 20–30 h): the open crash, hang and write-failure issues, plus an
@ -75,6 +76,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) |
| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) |
| 2026-10-11 | trivial | M7: formatting revisions landed (#41) |
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
| 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) |
| 2026-10-10 | trivial | Merged main: PRs that landed removed from the Current focus list |
| 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence |

View File

@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **481 / 496 (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 | **482 / 497 (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)) |

View File

@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFormat tables, arrow bullets, smileys inside words, undo after a border line, #546; previously minor: 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-11 · **Last updated:** 2026-10-11 · **Change:** 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
@ -102,6 +102,16 @@ Word.
- No line break right after a slash (`and/or`, URLs) unless the word can't fit a line
(`layout::tests::no_line_break_right_after_a_slash`).
## Complex scripts
- Myanmar, Khmer and the Indic scripts are typed in memory order (consonant, medials, then
vowel signs such as ေ, which is drawn before its consonant); the caret steps over whole
syllables (Myanmar vowel signs and stacked consonants never split off), Backspace removes one
character (vowel sign, medial, stacked consonant), Delete the whole syllable — the platforms'
standard behaviour, not yet compared against Word (#604,
`myanmar_typing_steps_by_syllable_and_backspaces_by_sign`,
`indic_and_khmer_backspace_removes_one_character`).
## Known gaps
- Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica
@ -129,6 +139,7 @@ Word.
| 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) |
| 2026-10-11 | minor | Editing options: typing replaces selection, overtype and the Insert key (#485) |
| 2026-10-11 | minor | Pasted paragraph breaks: tracked under Track Changes (#417), kept in a list by Keep Text Only (#422) |
| 2026-10-11 | minor | Complex scripts: caret by syllable, Backspace by character (#604) |
| 2026-10-11 | minor | Undo rows: a selection ends the typing group (#419); Undo of Bold at the caret restores unbolded typing (#424) |
| 2026-10-11 | minor | Names of days capitalised; AutoCorrect Options (user entries, deleted built-ins, exceptions, every AutoCorrect/AutoFormat switch) change typing (#412) |
| 2026-10-11 | minor | Editing keys: Move Paragraph Up/Down, Promote/Demote, Go Back (Shift+F5), Alt+X (#343) |

View File

@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Find and Replace special characters and wildcards, #543; 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; 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
@ -13,17 +13,19 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) |
| Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 |
| Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 |
| Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 |
| Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 |
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
| Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, 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 partial | rich per context | 60% | 3–5 |
| 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) | | 68% | 4–6 |
| 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) | | 68% | 4–6 |
| 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% | — |
| 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, Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 85% | 1 |
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
@ -45,6 +47,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-11 | trivial | Right-click on a picture, shape or text box selects it and offers Wrap Text: the seven wrapping styles (current one checked), Move with Text and Fix Position on Page (`arrange.moveWithText`) (#513) |
| 2026-10-11 | trivial | Paragraph dialog: Mirror indents (Inside/Outside labels), Don't add space between paragraphs of the same style, Formatting exceptions (Suppress line numbers, Don't hyphenate) (#512) |
| 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-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) |

View File

@ -21,7 +21,9 @@ Citations (each runs one of the commands below).
They run in the app (and through the control channel and MCP bridged to the app). A command
returns at once with `{"started": …}`; Zotero then works in its own window and WordCraft answers
it in the background. All changes from one command are one undo step.
it in the background. All changes from one command are one undo step. If New or Open replaces
the document while a command is running, Zotero's remaining calls for it get an error and the
new document is left as it is.
From a terminal, against a file: