diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca4b173..6a73f61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index 227247a..29ad4eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5131,6 +5131,7 @@ dependencies = [ "log", "skrifa 0.47.0", "unicode-bidi", + "unicode-script", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 98af82c..c0b3b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/ROADMAP.md b/ROADMAP.md index 7aeed3a..527b11f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (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 | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 3bd6f73..d3355ec 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -12,7 +12,10 @@ const USAGE: &str = "\ wordcraft-cli — WordCraft from the command line USAGE: - wordcraft-cli convert convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png) + wordcraft-cli convert [--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 pages, words, paragraphs, properties (JSON) wordcraft-cli text plain text wordcraft-cli inspect 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}"); diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index fdcc87d..978bdc4 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -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, diff --git a/contributors/people.toml b/contributors/people.toml index 16c281c..766bfd8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" diff --git a/crates/doc/src/cluster.rs b/crates/doc/src/cluster.rs new file mode 100644 index 0000000..b41a8fc --- /dev/null +++ b/crates/doc/src/cluster.rs @@ -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 { + let mut out: Vec = Vec::new(); + let mut prev: Option = 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 { + 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('語')); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63b9eb2..4bdc068 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs new file mode 100644 index 0000000..73e4f5e --- /dev/null +++ b/crates/doc/src/link.rs @@ -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 { + 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,", + "file:///etc/passwd", + "ms-msdt:/id", + "", + ] { + assert!(!is_safe_href(bad), "{bad:?}"); + } + } +} diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 55beeff..8fec420 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -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 { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 2bfbefc..0053c03 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -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