diff --git a/README.md b/README.md index 3e27187..d273d49 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +**If the app quits right after its window appears with "Parent device is lost" in the log:** +WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no +`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603). +`WGPU_DX12_COMPILER` (`fxc`, `dxc`, `auto`) overrides this; with `dxc`, a matching +`dxcompiler.dll` *and* `dxil.dll` must sit next to `wordcraft.exe`. `WGPU_BACKEND = "vulkan"` +also avoids DirectX 12 altogether. + **If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL @@ -260,6 +267,17 @@ instead, without setting anything. The log file records the choice (lines starti RISC-V (riscv64): `wordcraft--linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+ (Ubuntu 24.04, Debian 13 and newer). +**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish +to the AUR itself. Install one of them: + +| Package | What it installs | Choose it when | +|---|---|---| +| [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) | +| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself | +| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build | + +For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`. + ### FreeBSD | Build | File | diff --git a/ROADMAP.md b/ROADMAP.md index 91518a3..138d271 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 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) | +| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | +| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | +| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) | | 2026-10-11 | Insert › SmartArt (#497): list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics laid out by our own code, Text Pane, SmartArt Design tab (Add Shape, Promote, Demote, layouts, colours, Reset); saved as DOCX diagram parts with the drawing. Catalog 410/441 (93.0%) | | 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) | | 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written | @@ -179,6 +183,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | +| 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) | diff --git a/apps/wordcraft/src/graphics.rs b/apps/wordcraft/src/graphics.rs index 85aa433..3ef5157 100644 --- a/apps/wordcraft/src/graphics.rs +++ b/apps/wordcraft/src/graphics.rs @@ -1,9 +1,10 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Windows graphics instance configuration (backends and the DirectX 12 shader compiler), applied +//! before wgpu can initialize any driver. use eframe::{ NativeOptions, egui_wgpu::WgpuSetup, - wgpu::{Backends, DeviceType}, + wgpu::{Backends, DeviceType, Dx12Compiler}, }; /// What the start-up probe learned about one DirectX 12 adapter. @@ -55,11 +56,38 @@ pub fn configure_backends(options: &mut NativeOptions, backends: Backends) { } } +/// The DirectX 12 shader compiler: FXC (`d3dcompiler_47.dll`, part of Windows) unless `env` +/// (normally `WGPU_DX12_COMPILER`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another. +/// +/// #603: wgpu's default (`Auto`) uses DXC, loading whichever `dxcompiler.dll` the DLL search path +/// finds first. WordCraft ships none, so that copy belongs to another program. Without a matching +/// `dxil.dll` beside it, DXC warns that the DXIL is unsigned; wgpu takes the warning for a compile +/// error, its own indirect-validation pipeline fails, the device is lost and eframe exits before +/// the first frame. FXC is always there and compiles everything egui's shaders need. +pub fn dx12_compiler(env: Option<&str>) -> Dx12Compiler { + let Some(value) = env else { return Dx12Compiler::Fxc }; + value.parse().unwrap_or_else(|e| { + log::warn!("graphics: WGPU_DX12_COMPILER={value:?} ignored ({e}), using FXC"); + Dx12Compiler::Fxc + }) +} + +/// Makes the wgpu instance eframe creates compile DirectX 12 shaders with `compiler`. +pub fn configure_dx12_compiler(options: &mut NativeOptions, compiler: Dx12Compiler) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.backend_options.dx12.shader_compiler = compiler; + } +} + /// Chooses the backends ([`choose_backends`]) and applies them to `options`. Without a /// `backend_override` this first probes DirectX 12 with a short-lived instance of its own, which /// is dropped before eframe creates the real one. #[cfg(target_os = "windows")] pub fn configure(options: &mut NativeOptions, backend_override: Option) { + // First, so the probe's instance doesn't load a stray `dxcompiler.dll` either. + let compiler = dx12_compiler(std::env::var("WGPU_DX12_COMPILER").ok().as_deref()); + log::info!("graphics: DirectX 12 shader compiler {compiler:?}"); + configure_dx12_compiler(options, compiler); let backends = match backend_override { Some(backends) => { log::info!("graphics: WGPU_BACKEND override, backends {backends:?}"); @@ -87,7 +115,11 @@ fn probe_dx12(options: &NativeOptions) -> Vec { let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else { return Vec::new(); }; - let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() }); + let instance = Instance::new(InstanceDescriptor { + backends: Backends::DX12, + backend_options: create.instance_descriptor.backend_options.clone(), + ..InstanceDescriptor::new_without_display_handle() + }); pollster::block_on(instance.enumerate_adapters(Backends::DX12)) .iter() .map(|adapter| { @@ -139,6 +171,23 @@ mod tests { } } + /// #603: DirectX 12 shaders compile with FXC, never a `dxcompiler.dll` found on the search + /// path, unless `WGPU_DX12_COMPILER` asks for it. + #[test] + fn dx12_compiles_with_fxc_unless_asked() { + assert!(matches!(dx12_compiler(None), Dx12Compiler::Fxc)); + assert!(matches!(dx12_compiler(Some("bogus")), Dx12Compiler::Fxc)); + assert!(matches!(dx12_compiler(Some("FXC")), Dx12Compiler::Fxc)); + assert!(matches!(dx12_compiler(Some("dxc")), Dx12Compiler::DynamicDxc { .. })); + assert!(matches!(dx12_compiler(Some("auto")), Dx12Compiler::Auto)); + let mut options = NativeOptions::default(); + configure_dx12_compiler(&mut options, dx12_compiler(None)); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + #[test] fn gl_when_dx12_has_no_usable_gpu() { // #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags. diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index f13c577..ae9d601 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -209,7 +209,8 @@ fn main() -> eframe::Result { options.viewport = options.viewport.with_icon(icon); } // Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when - // DirectX 12 has no GPU driver (see graphics.rs). + // DirectX 12 has no GPU driver, and compile DirectX 12 shaders with FXC, which needs no + // DLL of ours (#603; see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); let restored = saved_window(); diff --git a/crates/doc/src/cjk_numbers.rs b/crates/doc/src/cjk_numbers.rs new file mode 100644 index 0000000..64a1757 --- /dev/null +++ b/crates/doc/src/cjk_numbers.rs @@ -0,0 +1,373 @@ +//! East Asian list and page number formats (ECMA-376 §17.18.59 ST_NumberFormat): Chinese, +//! Japanese and Korean counting systems (with units 十/百/千 and myriads 万/億), legal (financial) +//! numerals, digit-by-digit ideographs, the heavenly stems and earthly branches, kana and hangul +//! sequences and enclosed numbers. Written from the number systems' rules; a value outside a +//! sequence's range falls back to plain decimal. + +/// Whether a counting system writes the "one" before a unit. +#[derive(Clone, Copy, PartialEq, Eq)] +enum OmitOne { + /// Always written: legal numerals (壹拾, 壱百). + Never, + /// Dropped only before a leading ten: 十, 十一, 十万 but 一百一十 (Chinese). + LeadingTen, + /// Dropped before 十, 百 and 千 inside every group of four: 十, 百, 千 but 一万 (Japanese, + /// Korean). + Units, +} + +/// A myriad-based counting system (groups of four digits, like 1|2345|6789). +struct Counting { + /// The digits 1 to 9 (index 0 unused). + digits: [char; 10], + /// The units ten, hundred, thousand. + units: [char; 3], + /// The myriads: ten thousand, hundred million. + myriads: [char; 2], + /// The character written once for a run of zeros between two digits (Chinese 零); `None` + /// skips zeros (Japanese, Korean). + zero: Option, + omit_one: OmitOne, +} + +impl Counting { + fn format(&self, n: u32) -> String { + if n == 0 { + return self.zero.unwrap_or('〇').to_string(); + } + let groups = [n / 100_000_000, (n / 10_000) % 10_000, n % 10_000]; + let mut out = String::new(); + let mut zero_pending = false; + for (gi, &g) in groups.iter().enumerate() { + if g == 0 { + zero_pending |= !out.is_empty(); + continue; + } + let ds = [g / 1000, (g / 100) % 10, (g / 10) % 10, g % 10]; + for (pos, &d) in ds.iter().enumerate() { + if d == 0 { + zero_pending |= !out.is_empty(); + continue; + } + if zero_pending && let Some(z) = self.zero { + out.push(z); + } + zero_pending = false; + // pos 0 = thousands, 1 = hundreds, 2 = tens, 3 = ones. + let unit = match pos { + 0 => self.units.get(2), + 1 => self.units.get(1), + 2 => self.units.first(), + _ => None, + }; + let omit = d == 1 + && unit.is_some() + && match self.omit_one { + OmitOne::Never => false, + OmitOne::LeadingTen => pos == 2 && out.is_empty(), + OmitOne::Units => true, + }; + if !omit { + out.push(self.digits.get(d as usize).copied().unwrap_or('?')); + } + if let Some(&u) = unit { + out.push(u); + } + } + match gi { + 0 => out.extend(self.myriads.get(1)), + 1 => out.extend(self.myriads.first()), + _ => {} + } + } + out + } +} + +const CN_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + +const CHINESE: Counting = + Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '亿'], zero: Some('零'), omit_one: OmitOne::LeadingTen }; +const TAIWANESE: Counting = + Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['萬', '億'], zero: Some('零'), omit_one: OmitOne::LeadingTen }; +const CHINESE_LEGAL: Counting = Counting { + digits: ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'], + units: ['拾', '佰', '仟'], + myriads: ['万', '亿'], + zero: Some('零'), + omit_one: OmitOne::Never, +}; +const TRADITIONAL_LEGAL: Counting = Counting { + digits: ['零', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖'], + units: ['拾', '佰', '仟'], + myriads: ['萬', '億'], + zero: Some('零'), + omit_one: OmitOne::Never, +}; +const JAPANESE: Counting = + Counting { digits: CN_DIGITS, units: ['十', '百', '千'], myriads: ['万', '億'], zero: None, omit_one: OmitOne::Units }; +const JAPANESE_LEGAL: Counting = Counting { + digits: ['〇', '壱', '弐', '参', '四', '伍', '六', '七', '八', '九'], + units: ['拾', '百', '阡'], + myriads: ['萬', '億'], + zero: None, + omit_one: OmitOne::Never, +}; +const KOREAN: Counting = Counting { + digits: ['영', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'], + units: ['십', '백', '천'], + myriads: ['만', '억'], + zero: None, + omit_one: OmitOne::Units, +}; + +/// 一, 二 … 十, 十一 … 九十九 (Chinese counting); from 100 on, digit by digit (一〇〇). +pub(crate) fn chinese_counting(n: u32) -> String { + if n < 100 { CHINESE.format(n) } else { digits(n, &CN_DIGITS) } +} + +/// Chinese counting with every unit: 一百零一, 一千, 一万. +pub(crate) fn chinese_counting_thousand(n: u32) -> String { + CHINESE.format(n) +} + +/// Simplified Chinese legal (financial) numerals: 壹, 贰, 叁 … 壹拾. +pub(crate) fn chinese_legal_simplified(n: u32) -> String { + CHINESE_LEGAL.format(n) +} + +/// Traditional Chinese legal numerals: 壹, 貳, 參 … 壹拾. +pub(crate) fn ideograph_legal_traditional(n: u32) -> String { + TRADITIONAL_LEGAL.format(n) +} + +/// Taiwanese counting: like Chinese counting below 100, digit by digit above. +pub(crate) fn taiwanese_counting(n: u32) -> String { + if n < 100 { TAIWANESE.format(n) } else { digits(n, &CN_DIGITS) } +} + +/// Taiwanese counting with every unit: 一百零一, 一千, 一萬. +pub(crate) fn taiwanese_counting_thousand(n: u32) -> String { + TAIWANESE.format(n) +} + +/// Japanese counting: 一 … 十, 十一, 百, 千, 一万. +pub(crate) fn japanese_counting(n: u32) -> String { + JAPANESE.format(n) +} + +/// Japanese legal numerals: 壱, 弐, 参 … 壱拾. +pub(crate) fn japanese_legal(n: u32) -> String { + JAPANESE_LEGAL.format(n) +} + +/// Japanese digits with 万/億 marking each group of four: 一二, 一万二三四五. +pub(crate) fn japanese_digital_ten_thousand(n: u32) -> String { + let groups = [(n / 100_000_000, Some('億')), ((n / 10_000) % 10_000, Some('万')), (n % 10_000, None)]; + let mut out = String::new(); + for (g, mark) in groups { + let s = digits(g, &CN_DIGITS); + if out.is_empty() { + if g == 0 && mark.is_some() { + continue; + } + } else { + // Inner groups keep their four places: 一万〇〇〇一. + out.extend(std::iter::repeat_n('〇', 4usize.saturating_sub(s.chars().count()))); + } + out.push_str(&s); + out.extend(mark); + } + out +} + +/// Sino-Korean numbers in hangul: 일, 이, 삼 … 십, 십일, 백. +pub(crate) fn korean_counting(n: u32) -> String { + KOREAN.format(n) +} + +/// Sino-Korean digits in hangul, one per digit: 일, 일영, 일일. +pub(crate) fn korean_digital(n: u32) -> String { + digits(n, &KOREAN.digits) +} + +const KO_ONES: [&str; 10] = ["", "하나", "둘", "셋", "넷", "다섯", "여섯", "일곱", "여덟", "아홉"]; +const KO_TENS: [&str; 10] = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"]; + +/// Native Korean numbers: 하나, 둘, 셋 … 열, 열하나 … 아흔아홉 (decimal from 100 on). +pub(crate) fn korean_legal(n: u32) -> String { + if n == 0 || n > 99 { + return n.to_string(); + } + let (t, o) = ((n / 10) as usize, (n % 10) as usize); + format!("{}{}", KO_TENS.get(t).copied().unwrap_or(""), KO_ONES.get(o).copied().unwrap_or("")) +} + +/// One ideograph per decimal digit: 一, 一〇, 一〇一. +pub(crate) fn ideograph_digital(n: u32) -> String { + digits(n, &CN_DIGITS) +} + +fn digits(n: u32, set: &[char; 10]) -> String { + n.to_string().bytes().map(|b| set.get(b.saturating_sub(b'0') as usize).copied().unwrap_or('?')).collect() +} + +const STEMS: [char; 10] = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']; +const BRANCHES: [char; 12] = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']; + +/// The ten heavenly stems: 甲, 乙, 丙 … 癸. +pub(crate) fn ideograph_traditional(n: u32) -> String { + nth(&STEMS, n) +} + +/// The twelve earthly branches: 子, 丑, 寅 … 亥. +pub(crate) fn ideograph_zodiac(n: u32) -> String { + nth(&BRANCHES, n) +} + +/// The sixty-year cycle: 甲子, 乙丑, 丙寅 … 癸亥. +pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String { + if !(1..=60).contains(&n) { + return n.to_string(); + } + let i = (n - 1) as usize; + [STEMS.get(i % 10), BRANCHES.get(i % 12)].into_iter().flatten().collect() +} + +/// The `n`th (1-based) item of a sequence, or decimal past its end. +fn nth(set: &[char], n: u32) -> String { + n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string()) +} + +/// Code points `first..` enclosing 1 to `count`, decimal beyond. +fn enclosed(n: u32, first: u32, count: u32) -> String { + if (1..=count).contains(&n) + && let Some(c) = char::from_u32(first + n - 1) + { + return c.to_string(); + } + n.to_string() +} + +/// ① ② ③ … ⑳. +pub(crate) fn decimal_enclosed_circle(n: u32) -> String { + enclosed(n, 0x2460, 20) +} + +/// ① … ⑩ (the Chinese set stops at ten). +pub(crate) fn decimal_enclosed_circle_chinese(n: u32) -> String { + enclosed(n, 0x2460, 10) +} + +/// ⒈ ⒉ ⒊ … ⒛. +pub(crate) fn decimal_enclosed_fullstop(n: u32) -> String { + enclosed(n, 0x2488, 20) +} + +/// ⑴ ⑵ ⑶ … ⒇. +pub(crate) fn decimal_enclosed_paren(n: u32) -> String { + enclosed(n, 0x2474, 20) +} + +/// ㊀ ㊁ ㊂ … ㊉ (circled ideographs). +pub(crate) fn ideograph_enclosed_circle(n: u32) -> String { + enclosed(n, 0x3280, 10) +} + +/// Full-width digits: 1, 2 … 10. +pub(crate) fn decimal_full_width(n: u32) -> String { + n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| char::from_u32(0xFF10 + d)).unwrap_or(c)).collect() +} + +const AIUEO: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン"; +const AIUEO_HALF: &str = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン"; +const IROHA: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス"; +const IROHA_HALF: &str = "イロハニホヘトチリヌルヲワカヨタレソツネナラムウヰノオクヤマケフコエテアサキユメミシヱヒモセス"; +const GANADA: &str = "가나다라마바사아자차카타파하"; +const CHOSUNG: &str = "ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎ"; + +fn nth_of(set: &str, n: u32) -> String { + n.checked_sub(1).and_then(|i| set.chars().nth(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string()) +} + +/// Half-width katakana in gojūon order: ア, イ, ウ … +pub(crate) fn aiueo(n: u32) -> String { + nth_of(AIUEO_HALF, n) +} + +/// Full-width katakana in gojūon order: ア, イ, ウ … +pub(crate) fn aiueo_full_width(n: u32) -> String { + nth_of(AIUEO, n) +} + +/// Half-width katakana in iroha order: イ, ロ, ハ … +pub(crate) fn iroha(n: u32) -> String { + nth_of(IROHA_HALF, n) +} + +/// Full-width katakana in iroha order: イ, ロ, ハ … +pub(crate) fn iroha_full_width(n: u32) -> String { + nth_of(IROHA, n) +} + +/// Hangul syllables: 가, 나, 다 … +pub(crate) fn ganada(n: u32) -> String { + nth_of(GANADA, n) +} + +/// Hangul initial consonants: ㄱ, ㄴ, ㄷ … +pub(crate) fn chosung(n: u32) -> String { + nth_of(CHOSUNG, n) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chinese_counting_systems() { + let c = |n| chinese_counting(n); + assert_eq!([c(1), c(2), c(3), c(10), c(11), c(20), c(21), c(99)], ["一", "二", "三", "十", "十一", "二十", "二十一", "九十九"]); + assert_eq!([c(100), c(101), c(1000)], ["一〇〇", "一〇一", "一〇〇〇"]); + let t = |n| chinese_counting_thousand(n); + assert_eq!( + [t(10), t(11), t(20), t(101), t(110), t(111), t(1000), t(1010)], + ["十", "十一", "二十", "一百零一", "一百一十", "一百一十一", "一千", "一千零一十"] + ); + assert_eq!([t(10_000), t(10_001), t(100_100), t(1_000_000), t(100_000_001)], ["一万", "一万零一", "十万零一百", "一百万", "一亿零一"]); + assert_eq!(t(u32::MAX), "四十二亿九千四百九十六万七千二百九十五"); + let l = |n| chinese_legal_simplified(n); + assert_eq!([l(1), l(3), l(10), l(11), l(101), l(1000)], ["壹", "叁", "壹拾", "壹拾壹", "壹佰零壹", "壹仟"]); + assert_eq!([taiwanese_counting_thousand(10_001), ideograph_legal_traditional(20)], ["一萬零一", "貳拾"]); + assert_eq!([ideograph_digital(10), ideograph_digital(101)], ["一〇", "一〇一"]); + } + + #[test] + fn japanese_and_korean() { + let j = |n| japanese_counting(n); + assert_eq!([j(10), j(11), j(20), j(101), j(1000), j(10_000), j(10_001)], ["十", "十一", "二十", "百一", "千", "一万", "一万一"]); + assert_eq!([japanese_legal(10), japanese_legal(1000)], ["壱拾", "壱阡"]); + let d = |n| japanese_digital_ten_thousand(n); + assert_eq!([d(12), d(10_000), d(12_345), d(100_000_000)], ["一二", "一万〇〇〇〇", "一万二三四五", "一億〇〇〇〇万〇〇〇〇"]); + assert_eq!([korean_counting(11), korean_counting(20), korean_counting(101)], ["십일", "이십", "백일"]); + assert_eq!( + [korean_digital(10), korean_legal(1), korean_legal(11), korean_legal(20), korean_legal(100)], + ["일영", "하나", "열하나", "스물", "100"] + ); + } + + #[test] + fn sequences_and_enclosed_numbers() { + assert_eq!([ideograph_traditional(1), ideograph_traditional(10), ideograph_traditional(11)], ["甲", "癸", "11"]); + assert_eq!([ideograph_zodiac(1), ideograph_zodiac(12), ideograph_zodiac(13)], ["子", "亥", "13"]); + assert_eq!([ideograph_zodiac_traditional(1), ideograph_zodiac_traditional(11), ideograph_zodiac_traditional(60)], ["甲子", "甲戌", "癸亥"]); + assert_eq!([decimal_enclosed_circle(1), decimal_enclosed_circle(20), decimal_enclosed_circle(21)], ["①", "⑳", "21"]); + assert_eq!([decimal_enclosed_circle_chinese(10), decimal_enclosed_circle_chinese(11)], ["⑩", "11"]); + assert_eq!([decimal_enclosed_fullstop(1), decimal_enclosed_paren(20), ideograph_enclosed_circle(1)], ["⒈", "⒇", "㊀"]); + assert_eq!(decimal_full_width(10), "10"); + assert_eq!([aiueo_full_width(1), aiueo_full_width(46), aiueo_full_width(47), aiueo(2)], ["ア", "ン", "47", "イ"]); + assert_eq!([iroha_full_width(1), iroha_full_width(47), iroha(3)], ["イ", "ス", "ハ"]); + assert_eq!([ganada(1), chosung(14), ganada(0)], ["가", "ㅎ", "0"]); + assert_eq!([AIUEO.chars().count(), AIUEO_HALF.chars().count(), IROHA.chars().count(), IROHA_HALF.chars().count()], [46, 46, 47, 47]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5dba456..63b9eb2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod bidi; pub mod chart; +mod cjk_numbers; pub mod connector; pub mod control; pub mod edit; diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 5b61a82..655937d 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -300,7 +300,13 @@ impl Counters { let k = (d as usize).saturating_sub(1).min(8); let val = vals.get(k).copied().unwrap_or(0); let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val }; - let fmt = if def.legal && k != lv { NumFormat::Decimal } else { level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) }; + // Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own + // format (二 of a Chinese outline becomes 2 in "2.1"). + let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) { + NumFormat::DecimalZero => NumFormat::DecimalZero, + _ if def.legal => NumFormat::Decimal, + f => f, + }; label.push_str(&fmt.format(val)); } else { label.push(c); @@ -371,6 +377,32 @@ mod tests { assert!(c.next_label(&n, 999, 0).is_none()); } + #[test] + fn east_asian_labels_and_legal_numbering() { + // A Chinese outline: 第一章, then "一、" items, and a legal level showing 1.1. + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + if let Some(a) = n.abstracts.first_mut() { + a.levels[0] = Level { format: NumFormat::ChineseCounting, text: "第%1章".into(), ..Level::default() }; + a.levels[1] = Level { format: NumFormat::ChineseCounting, text: "%2、".into(), ..Level::default() }; + a.levels[2] = Level { format: NumFormat::ChineseCounting, text: "%1.%2.%3".into(), legal: true, ..Level::default() }; + a.levels[3] = Level { format: NumFormat::ChineseCounting, text: "(%4)".into(), ..Level::default() }; + } + let mut c = Counters::default(); + let mut l = |lv| c.next_label(&n, id, lv).unwrap().0; + assert_eq!(l(0), "第一章"); + assert_eq!(l(1), "一、"); + assert_eq!(l(1), "二、"); + assert_eq!(l(1), "三、"); + assert_eq!(l(2), "1.3.1", "legal numbering is decimal on every level"); + assert_eq!(l(3), "(一)"); + for _ in 0..9 { + l(3); + } + assert_eq!(l(3), "(十一)"); + assert_eq!(l(0), "第二章"); + } + #[test] fn legal_labels() { let mut n = Numbering::default(); diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index b502800..60f7123 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -15,6 +15,9 @@ pub enum SectionStart { NextColumn, } +/// A number format (ECMA-376 §17.18.59 ST_NumberFormat). Every value the format defines is +/// kept, so a document saves back the format it was opened with even where WordCraft draws it as +/// plain decimal (see [`NumFormat::format`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum NumFormat { @@ -30,9 +33,127 @@ pub enum NumFormat { DecimalZero, Bullet, None, + Hex, + Chicago, + IdeographDigital, + JapaneseCounting, + Aiueo, + Iroha, + DecimalFullWidth, + DecimalHalfWidth, + JapaneseLegal, + JapaneseDigitalTenThousand, + DecimalEnclosedCircle, + DecimalFullWidth2, + AiueoFullWidth, + IrohaFullWidth, + Ganada, + Chosung, + DecimalEnclosedFullstop, + DecimalEnclosedParen, + DecimalEnclosedCircleChinese, + IdeographEnclosedCircle, + IdeographTraditional, + IdeographZodiac, + IdeographZodiacTraditional, + TaiwaneseCounting, + IdeographLegalTraditional, + TaiwaneseCountingThousand, + TaiwaneseDigital, + ChineseCounting, + ChineseLegalSimplified, + ChineseCountingThousand, + KoreanDigital, + KoreanCounting, + KoreanLegal, + KoreanDigital2, + VietnameseCounting, + RussianLower, + RussianUpper, + NumberInDash, + Hebrew1, + Hebrew2, + ArabicAlpha, + ArabicAbjad, + HindiVowels, + HindiConsonants, + HindiNumbers, + HindiCounting, + ThaiLetters, + ThaiNumbers, + ThaiCounting, + BahtText, + DollarText, } impl NumFormat { + /// Every format, in the order ST_NumberFormat lists them (which is also the order of the + /// binary .doc number format codes up to 0x27). + pub const ALL: [NumFormat; 62] = [ + NumFormat::Decimal, + NumFormat::UpperRoman, + NumFormat::LowerRoman, + NumFormat::UpperLetter, + NumFormat::LowerLetter, + NumFormat::Ordinal, + NumFormat::CardinalText, + NumFormat::OrdinalText, + NumFormat::Hex, + NumFormat::Chicago, + NumFormat::IdeographDigital, + NumFormat::JapaneseCounting, + NumFormat::Aiueo, + NumFormat::Iroha, + NumFormat::DecimalFullWidth, + NumFormat::DecimalHalfWidth, + NumFormat::JapaneseLegal, + NumFormat::JapaneseDigitalTenThousand, + NumFormat::DecimalEnclosedCircle, + NumFormat::DecimalFullWidth2, + NumFormat::AiueoFullWidth, + NumFormat::IrohaFullWidth, + NumFormat::DecimalZero, + NumFormat::Bullet, + NumFormat::Ganada, + NumFormat::Chosung, + NumFormat::DecimalEnclosedFullstop, + NumFormat::DecimalEnclosedParen, + NumFormat::DecimalEnclosedCircleChinese, + NumFormat::IdeographEnclosedCircle, + NumFormat::IdeographTraditional, + NumFormat::IdeographZodiac, + NumFormat::IdeographZodiacTraditional, + NumFormat::TaiwaneseCounting, + NumFormat::IdeographLegalTraditional, + NumFormat::TaiwaneseCountingThousand, + NumFormat::TaiwaneseDigital, + NumFormat::ChineseCounting, + NumFormat::ChineseLegalSimplified, + NumFormat::ChineseCountingThousand, + NumFormat::KoreanDigital, + NumFormat::KoreanCounting, + NumFormat::KoreanLegal, + NumFormat::KoreanDigital2, + NumFormat::VietnameseCounting, + NumFormat::RussianLower, + NumFormat::RussianUpper, + NumFormat::None, + NumFormat::NumberInDash, + NumFormat::Hebrew1, + NumFormat::Hebrew2, + NumFormat::ArabicAlpha, + NumFormat::ArabicAbjad, + NumFormat::HindiVowels, + NumFormat::HindiConsonants, + NumFormat::HindiNumbers, + NumFormat::HindiCounting, + NumFormat::ThaiLetters, + NumFormat::ThaiNumbers, + NumFormat::ThaiCounting, + NumFormat::BahtText, + NumFormat::DollarText, + ]; + pub fn ooxml(self) -> &'static str { match self { NumFormat::Decimal => "decimal", @@ -46,30 +167,71 @@ impl NumFormat { NumFormat::DecimalZero => "decimalZero", NumFormat::Bullet => "bullet", NumFormat::None => "none", + NumFormat::Hex => "hex", + NumFormat::Chicago => "chicago", + NumFormat::IdeographDigital => "ideographDigital", + NumFormat::JapaneseCounting => "japaneseCounting", + NumFormat::Aiueo => "aiueo", + NumFormat::Iroha => "iroha", + NumFormat::DecimalFullWidth => "decimalFullWidth", + NumFormat::DecimalHalfWidth => "decimalHalfWidth", + NumFormat::JapaneseLegal => "japaneseLegal", + NumFormat::JapaneseDigitalTenThousand => "japaneseDigitalTenThousand", + NumFormat::DecimalEnclosedCircle => "decimalEnclosedCircle", + NumFormat::DecimalFullWidth2 => "decimalFullWidth2", + NumFormat::AiueoFullWidth => "aiueoFullWidth", + NumFormat::IrohaFullWidth => "irohaFullWidth", + NumFormat::Ganada => "ganada", + NumFormat::Chosung => "chosung", + NumFormat::DecimalEnclosedFullstop => "decimalEnclosedFullstop", + NumFormat::DecimalEnclosedParen => "decimalEnclosedParen", + NumFormat::DecimalEnclosedCircleChinese => "decimalEnclosedCircleChinese", + NumFormat::IdeographEnclosedCircle => "ideographEnclosedCircle", + NumFormat::IdeographTraditional => "ideographTraditional", + NumFormat::IdeographZodiac => "ideographZodiac", + NumFormat::IdeographZodiacTraditional => "ideographZodiacTraditional", + NumFormat::TaiwaneseCounting => "taiwaneseCounting", + NumFormat::IdeographLegalTraditional => "ideographLegalTraditional", + NumFormat::TaiwaneseCountingThousand => "taiwaneseCountingThousand", + NumFormat::TaiwaneseDigital => "taiwaneseDigital", + NumFormat::ChineseCounting => "chineseCounting", + NumFormat::ChineseLegalSimplified => "chineseLegalSimplified", + NumFormat::ChineseCountingThousand => "chineseCountingThousand", + NumFormat::KoreanDigital => "koreanDigital", + NumFormat::KoreanCounting => "koreanCounting", + NumFormat::KoreanLegal => "koreanLegal", + NumFormat::KoreanDigital2 => "koreanDigital2", + NumFormat::VietnameseCounting => "vietnameseCounting", + NumFormat::RussianLower => "russianLower", + NumFormat::RussianUpper => "russianUpper", + NumFormat::NumberInDash => "numberInDash", + NumFormat::Hebrew1 => "hebrew1", + NumFormat::Hebrew2 => "hebrew2", + NumFormat::ArabicAlpha => "arabicAlpha", + NumFormat::ArabicAbjad => "arabicAbjad", + NumFormat::HindiVowels => "hindiVowels", + NumFormat::HindiConsonants => "hindiConsonants", + NumFormat::HindiNumbers => "hindiNumbers", + NumFormat::HindiCounting => "hindiCounting", + NumFormat::ThaiLetters => "thaiLetters", + NumFormat::ThaiNumbers => "thaiNumbers", + NumFormat::ThaiCounting => "thaiCounting", + NumFormat::BahtText => "bahtText", + NumFormat::DollarText => "dollarText", } } + /// The format an ST_NumberFormat value names; a value the format doesn't define is decimal + /// (as Word shows it). So is `custom` (a Word 2010 format string, always written with a + /// decimal fallback). pub fn from_ooxml(s: &str) -> NumFormat { - [ - NumFormat::Decimal, - NumFormat::UpperRoman, - NumFormat::LowerRoman, - NumFormat::UpperLetter, - NumFormat::LowerLetter, - NumFormat::Ordinal, - NumFormat::CardinalText, - NumFormat::OrdinalText, - NumFormat::DecimalZero, - NumFormat::Bullet, - NumFormat::None, - ] - .into_iter() - .find(|f| f.ooxml() == s) - .unwrap_or(NumFormat::Decimal) + NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal) } - /// Format `n` (1-based). + /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai, + /// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end + /// (the 21st item of ①…⑳). pub fn format(self, n: u32) -> String { + use crate::cjk_numbers as cjk; match self { - NumFormat::Decimal => n.to_string(), NumFormat::DecimalZero => format!("{n:02}"), NumFormat::UpperRoman => roman(n).to_uppercase(), NumFormat::LowerRoman => roman(n), @@ -79,10 +241,70 @@ impl NumFormat { NumFormat::CardinalText => cardinal_text(n), NumFormat::OrdinalText => ordinal_text(n), NumFormat::Bullet | NumFormat::None => String::new(), + NumFormat::Chicago => chicago(n), + NumFormat::NumberInDash => format!("- {n} -"), + NumFormat::IdeographDigital | NumFormat::KoreanDigital2 | NumFormat::TaiwaneseDigital => cjk::ideograph_digital(n), + NumFormat::JapaneseCounting => cjk::japanese_counting(n), + NumFormat::JapaneseLegal => cjk::japanese_legal(n), + NumFormat::JapaneseDigitalTenThousand => cjk::japanese_digital_ten_thousand(n), + NumFormat::Aiueo => cjk::aiueo(n), + NumFormat::AiueoFullWidth => cjk::aiueo_full_width(n), + NumFormat::Iroha => cjk::iroha(n), + NumFormat::IrohaFullWidth => cjk::iroha_full_width(n), + NumFormat::DecimalFullWidth | NumFormat::DecimalFullWidth2 => cjk::decimal_full_width(n), + NumFormat::DecimalEnclosedCircle => cjk::decimal_enclosed_circle(n), + NumFormat::DecimalEnclosedCircleChinese => cjk::decimal_enclosed_circle_chinese(n), + NumFormat::DecimalEnclosedFullstop => cjk::decimal_enclosed_fullstop(n), + NumFormat::DecimalEnclosedParen => cjk::decimal_enclosed_paren(n), + NumFormat::IdeographEnclosedCircle => cjk::ideograph_enclosed_circle(n), + NumFormat::IdeographTraditional => cjk::ideograph_traditional(n), + NumFormat::IdeographZodiac => cjk::ideograph_zodiac(n), + NumFormat::IdeographZodiacTraditional => cjk::ideograph_zodiac_traditional(n), + NumFormat::IdeographLegalTraditional => cjk::ideograph_legal_traditional(n), + NumFormat::TaiwaneseCounting => cjk::taiwanese_counting(n), + NumFormat::TaiwaneseCountingThousand => cjk::taiwanese_counting_thousand(n), + NumFormat::ChineseCounting => cjk::chinese_counting(n), + NumFormat::ChineseCountingThousand => cjk::chinese_counting_thousand(n), + NumFormat::ChineseLegalSimplified => cjk::chinese_legal_simplified(n), + NumFormat::KoreanCounting => cjk::korean_counting(n), + NumFormat::KoreanDigital => cjk::korean_digital(n), + NumFormat::KoreanLegal => cjk::korean_legal(n), + NumFormat::Ganada => cjk::ganada(n), + NumFormat::Chosung => cjk::chosung(n), + NumFormat::Decimal + | NumFormat::DecimalHalfWidth + | NumFormat::Hex + | NumFormat::VietnameseCounting + | NumFormat::RussianLower + | NumFormat::RussianUpper + | NumFormat::Hebrew1 + | NumFormat::Hebrew2 + | NumFormat::ArabicAlpha + | NumFormat::ArabicAbjad + | NumFormat::HindiVowels + | NumFormat::HindiConsonants + | NumFormat::HindiNumbers + | NumFormat::HindiCounting + | NumFormat::ThaiLetters + | NumFormat::ThaiNumbers + | NumFormat::ThaiCounting + | NumFormat::BahtText + | NumFormat::DollarText => n.to_string(), } } } +/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on. +fn chicago(n: u32) -> String { + const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}']; + if n == 0 { + return String::new(); + } + let i = (n - 1) as usize; + let k = (i / 4 + 1).min(10); + std::iter::repeat_n(MARKS.get(i % 4).copied().unwrap_or('*'), k).collect() +} + fn ordinal_suffix(n: u32) -> &'static str { match (n % 10, n % 100) { (_, 11..=13) => "th", @@ -604,6 +826,16 @@ mod tests { assert_eq!(NumFormat::DecimalZero.format(3), "03"); assert!(!NumFormat::UpperRoman.format(u32::MAX).is_empty()); assert_eq!(NumFormat::LowerLetter.format(u32::MAX).len(), 30); + assert_eq!([NumFormat::Chicago.format(1), NumFormat::Chicago.format(6)], ["*", "\u{2020}\u{2020}"]); + assert_eq!(NumFormat::ChineseCounting.format(11), "十一"); + assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一"); + assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③"); + assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal"); + for f in NumFormat::ALL { + assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips"); + assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}"); + } + assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal"); } #[test] diff --git a/crates/docbin/src/list.rs b/crates/docbin/src/list.rs index 00b8da8..6038821 100644 --- a/crates/docbin/src/list.rs +++ b/crates/docbin/src/list.rs @@ -47,23 +47,29 @@ fn bullet_char(c: u16) -> char { } } -/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). +/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). Codes +/// 0x00 to 0x27 run in the order of ST_NumberFormat (decimal, upperRoman … 0x16 decimalZero, 0x17 +/// bullet … 0x25 chineseCounting, 0x26 chineseLegalSimplified, 0x27 chineseCountingThousand); +/// later codes are drawn as decimal. fn num_format(nfc: u8) -> NumFormat { match nfc { - 0x01 => NumFormat::UpperRoman, - 0x02 => NumFormat::LowerRoman, - 0x03 => NumFormat::UpperLetter, - 0x04 => NumFormat::LowerLetter, - 0x05 => NumFormat::Ordinal, - 0x06 => NumFormat::CardinalText, - 0x07 => NumFormat::OrdinalText, - 0x0A | 0x0B => NumFormat::DecimalZero, - 0x17 => NumFormat::Bullet, 0xFF => NumFormat::None, + 0x00..=0x27 => NumFormat::ALL.get(nfc as usize).copied().unwrap_or(NumFormat::Decimal), _ => NumFormat::Decimal, } } +#[cfg(test)] +#[test] +fn number_format_codes() { + assert_eq!(num_format(0x04), NumFormat::LowerLetter); + assert_eq!(num_format(0x16), NumFormat::DecimalZero); + assert_eq!(num_format(0x17), NumFormat::Bullet); + assert_eq!(num_format(0x25), NumFormat::ChineseCounting); + assert_eq!(num_format(0x27), NumFormat::ChineseCountingThousand); + assert_eq!(num_format(0x60), NumFormat::Decimal); +} + /// Read the lists of a document; empty when the FIB carries none (or they are unreadable). pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering { let defs = match fib.pair(pair::PLF_LST) { diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index dd4639f..590d607 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1036,8 +1036,10 @@ impl Reader<'_> { } } -fn default_level(i: usize) -> Level { - Level { indent: 36.0 * (i as f32 + 1.0), text: String::new(), ..Level::default() } +/// A level as the file defines it before its own elements: no indents (a level without `w:ind` +/// leaves the label at the paragraph's left edge, as Word shows it), no text. +fn default_level(_i: usize) -> Level { + Level { indent: 0.0, hanging: 0.0, text: String::new(), ..Level::default() } } /// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29: diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index e67d413..702865c 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -41,9 +41,10 @@ enum Phase { Result, } +/// A buffered piece of a field's result. Objects are boxed: an inline object is much larger than +/// a string. enum Item { Text(String), - // Field results mostly contain text; a picture/shape must not enlarge every buffer item. Obj(Box), } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 8d454eb..2d67a0a 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1175,6 +1175,54 @@ fn note_separators_and_continuation_notice_round_trip() { check(&wordcraft_docx::read(&out).unwrap()); } +/// Issue #534: East Asian number formats are read, labelled and written back as they were; a +/// value the format doesn't define is decimal, and a level without `w:ind` has no indent. +#[test] +fn east_asian_number_formats_read_label_and_round_trip() { + use wordcraft_doc::numbering::Counters; + use wordcraft_doc::section::NumFormat; + let lvl = |i: u8, fmt: &str, text: &str| { + format!(r#""#) + }; + let levels = [ + lvl(0, "chineseCounting", "%1、"), + lvl(1, "chineseCountingThousand", "第%2章"), + lvl(2, "japaneseLegal", "(%3)"), + lvl(3, "decimalEnclosedCircle", "%4"), + lvl(4, "hebrew1", "%5."), + lvl(5, "notAFormat", "%6."), + ] + .concat(); + let numbering = format!( + r#"{levels}"# + ); + let item = |lv: u8| format!(r#"x"#); + let body: String = [0, 0, 0, 1, 2, 3, 4, 5].map(item).concat(); + let bytes = docx(&body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]); + let d = wordcraft_docx::read(&bytes).unwrap(); + let formats = |d: &Document| (0..6).map(|i| d.numbering.level(1, i).map(|l| l.format)).collect::>(); + let want = [ + NumFormat::ChineseCounting, + NumFormat::ChineseCountingThousand, + NumFormat::JapaneseLegal, + NumFormat::DecimalEnclosedCircle, + NumFormat::Hebrew1, + NumFormat::Decimal, + ] + .map(Some); + assert_eq!(formats(&d), want); + assert_eq!(d.numbering.level(1, 0).map(|l| (l.indent, l.hanging)), Some((0.0, 0.0))); + let mut c = Counters::default(); + let labels: Vec = + paras(&d).iter().filter_map(|p| p.props.numbering).filter_map(|n| c.next_label(&d.numbering, n.num, n.level)).map(|l| l.0).collect(); + assert_eq!(labels, ["一、", "二、", "三、", "第一章", "(壱)", "①", "1.", "1."]); + // Saved and read again, every format is the one the file had (hebrew1 too, which shows as + // decimal for now). + let out = wordcraft_docx::write(&d).unwrap(); + let back = wordcraft_docx::read(&out).unwrap(); + assert_eq!(formats(&back), want); +} + /// #464: a bookmark whose end follows the document's final table keeps its end (it goes into /// the table's last cell) and is saved as a matched pair. #[test] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 26a2837..0a0682b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; use wordcraft_doc::{Pos, StoryRef}; -use super::{delete_selection, pos_json, sel_result}; +use super::{delete_selection, find_pattern, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; fn has_sel(s: &Session) -> Option<&'static str> { @@ -65,19 +65,19 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("edit.find", "Find", "Home › Editing", find) .key("Mod+F") - .params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) + .params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "matchPrefix"?: bool, "matchSuffix"?: bool, "ignorePunct"?: bool, "ignoreSpace"?: bool, "wildcards"?: bool, "special"?: bool (^p ^t ^# … codes, default true), "regex"?: bool}"#) .pure(), CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find) - .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#) + .params(r#"{"text"?: string, …options of edit.find, "in"?: "main|selection", "highlight"?: bool}"#) .pure(), CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3 / Mod+PageDown").pure(), CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)) // Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results. .key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp")) .pure(), - CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#), + CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) - .params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#), + .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto) .key("Mod+Alt+G / F5") .params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#) @@ -278,46 +278,153 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// One match: its range and, when asked for, the Replace with text filled in for it. +pub(crate) struct Found { + pub a: Pos, + pub b: Pos, + pub rep: Option, +} + +/// A stretch of searched text: `len` bytes from `hay` are paragraph `para`'s bytes from `raw`. +/// A paragraph mark is a one-byte stretch at the paragraph's end (`mark`). +struct Seg { + hay: usize, + para: usize, + raw: usize, + len: usize, + mark: bool, +} + /// All matches of the find state in the current story. Text marked deleted by /// Track Changes is no longer part of the document, so the search reads each /// paragraph without it: a match lies within one stretch of live text, and word /// boundaries and anchors see the neighbouring live text, not the deleted text. /// Otherwise Replace would find its own deleted text again. pub(crate) fn search(s: &Session, story: StoryRef) -> Result, CmdError> { + Ok(search_full(s, story, false)?.into_iter().map(|f| (f.a, f.b)).collect()) +} + +fn find_opts(f: &crate::session::FindState) -> find_pattern::Opts { + find_pattern::Opts { + match_case: f.match_case, + whole_word: f.whole_word, + prefix: f.match_prefix, + suffix: f.match_suffix, + ignore_punct: f.ignore_punct, + ignore_space: f.ignore_space, + wildcards: f.wildcards, + regex: f.regex, + literal: f.literal, + } +} + +/// [`search`], with the Replace with text of each match when `with_rep` (special codes, `^&`, +/// wildcard groups). A pattern that can match a paragraph mark (`^p`) reads runs of sibling +/// paragraphs as one text with a mark between them; otherwise each paragraph is read alone. +pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Result, CmdError> { use regex_automata::{Input, meta, util::syntax}; let f = &s.find; if f.query.is_empty() { return Ok(Vec::new()); } - let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; - let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat }; + let opts = find_opts(f); + let c = find_pattern::compile(&f.query, &opts).map_err(CmdError::Params)?; let re = meta::Regex::builder() - .syntax(syntax::Config::new().case_insensitive(!f.match_case)) + .syntax(syntax::Config::new().case_insensitive(c.case_insensitive)) .configure(meta::Config::new().nfa_size_limit(Some(1 << 20))) - .build(&pat) + .build(&c.pattern) .map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?; - let mut out = Vec::new(); - for path in s.doc.para_paths(story) { - let Some(p) = s.doc.para(story, &path) else { continue }; - // The paragraph as it reads, and its stretches of live text as - // (start in `live`, start in the paragraph, length). - let mut live = String::new(); - let mut spans: Vec<(usize, usize, usize)> = Vec::new(); - for (r, c) in p.run_ranges() { - let Some(t) = p.text.get(r.clone()).filter(|_| c.del.is_none()) else { continue }; - match spans.last_mut() { - Some((_, raw, n)) if *raw + *n == r.start => *n += t.len(), - _ => spans.push((live.len(), r.start, t.len())), - } - live.push_str(t); + let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts)); + let paths = s.doc.para_paths(story); + // Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs. + let mut chunks: Vec> = Vec::new(); + for i in 0..paths.len() { + let joins = + c.spans_paras && i > 0 && paths.get(i - 1).zip(paths.get(i)).is_some_and(|(x, y)| x.parent() == y.parent() && x.last() + 1 == y.last()); + match chunks.last_mut() { + Some(r) if joins => r.end = i + 1, + _ => chunks.push(i..i + 1), } - for (at, raw, n) in spans { - for m in re.find_iter(Input::new(&live).range(at..at + n)) { + } + let mut out = Vec::new(); + let mut caps = re.create_captures(); + for chunk in chunks { + let mut hay = String::new(); + let mut segs: Vec = Vec::new(); + // Where the live text breaks (tracked-deleted text, or a paragraph edge when not spanning). + let mut cuts: Vec = Vec::new(); + let last = chunk.end.saturating_sub(1); + for i in chunk { + let Some(p) = paths.get(i).and_then(|path| s.doc.para(story, path)) else { + cuts.push(hay.len()); + continue; + }; + let mut expect = 0; + for (r, ch) in p.run_ranges() { + let Some(t) = p.text.get(r.clone()).filter(|_| ch.del.is_none()) else { continue }; + if t.is_empty() { + continue; + } + if r.start != expect { + cuts.push(hay.len()); + } + match segs.last_mut() { + Some(sg) if !sg.mark && sg.para == i && sg.raw + sg.len == r.start => sg.len += t.len(), + _ => segs.push(Seg { hay: hay.len(), para: i, raw: r.start, len: t.len(), mark: false }), + } + hay.push_str(t); + expect = r.end; + } + if i < last { + if expect != p.text.len() { + cuts.push(hay.len()); + } + segs.push(Seg { hay: hay.len(), para: i, raw: p.text.len(), len: 1, mark: true }); + hay.push(if p.section.is_some() { find_pattern::SECT_MARK } else { find_pattern::PARA_MARK }); + } + } + let pos_of = |x: usize, end: bool| -> Option { + let k = segs.partition_point(|sg| if end { sg.hay + sg.len < x } else { sg.hay + sg.len <= x }); + let sg = segs.get(k)?; + if (end && sg.hay >= x) || (!end && sg.hay > x) { + return None; + } + if sg.mark && x > sg.hay { + let path = paths.get(sg.para)?; + return Some(Pos { story, path: path.with_last(path.last() + 1), off: 0 }); + } + Some(Pos { story, path: paths.get(sg.para)?.clone(), off: sg.raw + (x - sg.hay) }) + }; + cuts.push(hay.len()); + let mut from = 0; + for cut in cuts { + if cut <= from { + continue; + } + let mut input = Input::new(&hay).range(from..cut); + from = cut; + loop { + re.search_captures(&input, &mut caps); + let Some(m) = caps.get_match() else { break }; + // Step past an empty match so the search moves on. + let next = if m.is_empty() { + hay.get(m.end()..).and_then(|t| t.chars().next()).map_or(m.end() + 1, |c| m.end() + c.len_utf8()) + } else { + m.end() + }; + if next > input.end() { + break; + } + input.set_start(next); if m.is_empty() { continue; } - let (a, b) = (raw + m.start().saturating_sub(at), raw + m.end().saturating_sub(at)); - out.push((Pos { story, path: path.clone(), off: a }, Pos { story, path: path.clone(), off: b })); + let (Some(a), Some(b)) = (pos_of(m.start(), false), pos_of(m.end(), true)) else { continue }; + let rep = pieces.as_ref().map(|pc| { + let group = |n: usize| caps.get_group(n).and_then(|g| hay.get(g.range())).map(str::to_string); + find_pattern::expand(pc, hay.get(m.range()).unwrap_or(""), &group, &s.clipboard_text) + }); + out.push(Found { a, b, rep }); if out.len() >= 100_000 { return Ok(out); } @@ -342,6 +449,24 @@ fn read_opts(s: &mut Session, v: &Value) { if let Some(b) = p::bool(v, "regex") { s.find.regex = b; } + if let Some(b) = p::bool(v, "wildcards") { + s.find.wildcards = b; + } + if let Some(b) = p::bool(v, "special") { + s.find.literal = !b; + } + if let Some(b) = p::bool(v, "matchPrefix") { + s.find.match_prefix = b; + } + if let Some(b) = p::bool(v, "matchSuffix") { + s.find.match_suffix = b; + } + if let Some(b) = p::bool(v, "ignorePunct") { + s.find.ignore_punct = b; + } + if let Some(b) = p::bool(v, "ignoreSpace") { + s.find.ignore_space = b; + } if let Some(t) = p::str(v, "with") { s.find.replace = t.to_string(); } @@ -421,25 +546,25 @@ fn step(s: &mut Session, dir: i64) -> CmdResult { fn replace(s: &mut Session, v: &Value) -> CmdResult { read_opts(s, v); let story = s.sel.focus.story; - let results = search(s, story)?; + let results = search_full(s, story, true)?; let (a, b) = s.sel.ordered(); // Replace the current match if selected, then move to the next. - if results.iter().any(|(x, y)| *x == a && *y == b) { - let with = s.find.replace.clone(); - super::paste_text(s, &with)?; + if let Some(f) = results.into_iter().find(|f| f.a == a && f.b == b) { + super::paste_text(s, &f.rep.unwrap_or_default())?; } let _ = step(s, 1); Ok(json!({"remaining": search(s, story)?.len()})) } +/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in +/// the replacement (`^p`) splits the paragraph. fn replace_all(s: &mut Session, v: &Value) -> CmdResult { read_opts(s, v); let story = s.sel.focus.story; - let results = search(s, story)?; - let with = s.find.replace.clone(); + let results = search_full(s, story, true)?; let n = results.len(); - // Replace from the end so earlier positions stay valid. - for (a, b) in results.into_iter().rev() { + for Found { a, b, rep } in results.into_iter().rev() { + let with = rep.unwrap_or_default(); if s.doc.settings.track_changes { // Same path as a single replace, so the change is a reviewable revision. s.sel = Selection { anchor: a, focus: b }; @@ -447,11 +572,9 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult { continue; } let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default(); - s.doc.delete_range(&a, &b)?; - // A paragraph mark in the replacement starts a new paragraph, as in a single replace. - let mut at = a; - for (i, part) in with.split(['\r', '\u{2029}']).enumerate() { - if i > 0 { + let mut at = s.doc.delete_range(&a, &b)?; + for (k, part) in with.split(['\r', '\u{2029}']).enumerate() { + if k > 0 { at = s.doc.split_paragraph(&at)?; } if !part.is_empty() { diff --git a/crates/engine/src/cmd/find_pattern.rs b/crates/engine/src/cmd/find_pattern.rs new file mode 100644 index 0000000..2c39bca --- /dev/null +++ b/crates/engine/src/cmd/find_pattern.rs @@ -0,0 +1,417 @@ +//! Word's Find and Replace patterns (#543): special characters (`^p`, `^t`, `^#` …) and the +//! "Use wildcards" language (`?`, `*`, `[a-z]`, `{n,m}`, `@`, `<`, `>`, `( )`), translated to a +//! regular expression for the linear-time `regex` engine, so no pattern can backtrack +//! catastrophically. Replace text understands the same codes plus `^&` (the found text), `^c` +//! (the clipboard) and, with wildcards, `\1`–`\9` (the groups). +//! +//! The search reads a story as text with a paragraph mark (`\r`, [`PARA_MARK`]) between +//! paragraphs when the pattern can match one, so `^p` finds paragraph ends; a paragraph that ends +//! a section ends with [`SECT_MARK`] instead (`^b`). + +use regex::escape; + +/// A paragraph mark in the searched text. +pub const PARA_MARK: char = '\r'; +/// The mark of a paragraph that ends a section (`^b`), in the searched text only. +pub const SECT_MARK: char = '\u{1D}'; +/// Longest Find what text, in characters. +pub const MAX_PATTERN: usize = 1024; +/// Largest count in `{n,m}`. +const MAX_COUNT: u32 = 1000; +/// Any character except a paragraph mark (`?`, `^?`, `*`). +const ANY: &str = r"[^\r\x{1D}]"; + +/// How the Find what text is read. +#[derive(Clone, Copy, Debug, Default)] +pub struct Opts { + pub match_case: bool, + pub whole_word: bool, + pub prefix: bool, + pub suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// Word's "Use wildcards". + pub wildcards: bool, + /// A regular expression (WordCraft's own option; wins over wildcards). + pub regex: bool, + /// Take the text literally: no `^` codes. + pub literal: bool, +} + +/// A pattern ready for the regex engine. +#[derive(Debug)] +pub struct Compiled { + pub pattern: String, + pub case_insensitive: bool, + /// The pattern can match a paragraph mark, so the search must read across paragraphs. + pub spans_paras: bool, +} + +/// What a `^` code stands for in Find what. +fn find_code(c: char) -> Option<&'static str> { + Some(match c { + 'p' => r"\r", + 'b' => r"\x{1D}", + 't' => r"\t", + '?' => ANY, + '#' => "[0-9]", + '$' => r"\p{L}", + '^' => r"\^", + 'l' => r"\n", + 'm' => r"\x{0C}", + 'n' => r"\x{0E}", + '+' => r"\x{2014}", + '=' => r"\x{2013}", + 's' => r"\x{A0}", + '~' => r"\x{2011}", + '-' => r"\x{AD}", + 'w' => r"[ \t\x{A0}]+", + 'g' => r"\x{FFFC}", + '%' => "§", + 'v' => "¶", + _ => return None, + }) +} + +/// What a `^` code stands for in Replace with. +fn replace_code(c: char) -> Option { + Some(match c { + 'p' => PARA_MARK, + 't' => '\t', + '^' => '^', + 'l' => '\n', + 'm' => '\u{0C}', + 'n' => '\u{0E}', + '+' => '\u{2014}', + '=' => '\u{2013}', + 's' => '\u{A0}', + '~' => '\u{2011}', + '-' => '\u{AD}', + '%' => '§', + 'v' => '¶', + _ => return None, + }) +} + +/// A character code (`^13`, `^0151`, `^u8212`) as the character it stands for here. +fn code_char(n: u32) -> Option { + Some(match n { + 13 => PARA_MARK, + 11 => '\n', + 12 => '\u{0C}', + 14 => '\u{0E}', + 30 => '\u{2011}', + 31 => '\u{AD}', + n => char::from_u32(n).filter(|c| !c.is_control() || *c == '\t')?, + }) +} + +/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the +/// character and how many characters it took. +fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> { + let (start, max) = if chars.get(i) == Some(&'u') { (i + 1, 5) } else { (i, 4) }; + let digits: String = chars.get(start..).unwrap_or(&[]).iter().take(max).take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + let n: u32 = digits.parse().ok()?; + Some((code_char(n)?, start - i + digits.chars().count())) +} + +/// A literal character as a regex atom. +fn lit(c: char) -> String { + if c == PARA_MARK { + return r"\r".into(); + } + escape(c.encode_utf8(&mut [0; 4])) +} + +/// Translate Word's Find what text. +pub fn compile(query: &str, o: &Opts) -> Result { + if query.chars().count() > MAX_PATTERN { + return Err(format!("the Find what text is longer than {MAX_PATTERN} characters")); + } + let (pat, spans) = if o.regex { + (query.to_string(), false) + } else if o.literal { + (escape(query), false) + } else if o.wildcards { + wildcards(query)? + } else { + special(query, o) + }; + let wrap = |p: String| format!("(?:{p})"); + let pat = if o.wildcards && !o.regex { + pat + } else if o.whole_word { + format!(r"\b{}\b", wrap(pat)) + } else { + match (o.prefix, o.suffix) { + (true, true) => format!(r"\b{}\b", wrap(pat)), + (true, false) => format!(r"\b{}", wrap(pat)), + (false, true) => format!(r"{}\b", wrap(pat)), + (false, false) => pat, + } + }; + // Word's wildcard search always matches case. + let case_insensitive = !o.match_case && !(o.wildcards && !o.regex); + Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans }) +} + +/// Normal search: literal text with `^` codes, optionally skipping punctuation and white space. +fn special(query: &str, o: &Opts) -> (String, bool) { + let chars: Vec = query.chars().collect(); + let mut atoms: Vec = Vec::new(); + let mut spans = false; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + if c == '^' + && let Some(&n) = chars.get(i) + { + if let Some(code) = find_code(n) { + spans |= matches!(n, 'p' | 'b'); + atoms.push(code.to_string()); + i += 1; + continue; + } + if let Some((ch, used)) = numeric_code(&chars, i) { + spans |= ch == PARA_MARK; + atoms.push(lit(ch)); + i += used; + continue; + } + // Not a code: the caret itself. + } + if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) { + continue; + } + atoms.push(lit(c)); + } + let filler = match (o.ignore_punct, o.ignore_space) { + (true, true) => r"[\p{P} \t\x{A0}]*", + (true, false) => r"\p{P}*", + (false, true) => r"[ \t\x{A0}]*", + (false, false) => "", + }; + (atoms.join(filler), spans) +} + +fn is_punct(c: char) -> bool { + c.is_ascii_punctuation() || matches!(c, '\u{2010}'..='\u{2027}' | '\u{00A1}' | '\u{00BF}' | '\u{00AB}' | '\u{00BB}') +} + +fn is_space(c: char) -> bool { + matches!(c, ' ' | '\t' | '\u{A0}') +} + +/// "Use wildcards" search. +fn wildcards(query: &str) -> Result<(String, bool), String> { + let chars: Vec = query.chars().collect(); + let mut out = String::new(); + let mut spans = false; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + match c { + '?' => out.push_str(ANY), + '*' => { + out.push_str(ANY); + out.push_str("*?"); + } + '@' => out.push('+'), + '<' => out.push_str(r"\b{start}"), + '>' => out.push_str(r"\b{end}"), + '(' | ')' => out.push(c), + '{' => { + let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == '}')).ok_or("a { needs a closing }")?; + let body: String = chars.get(i..i + end).unwrap_or(&[]).iter().collect(); + out.push_str(&count(&body)?); + i += end + 1; + } + '[' => { + let end = chars.get(i..).and_then(|r| r.iter().position(|c| *c == ']')).ok_or("a [ needs a closing ]")?; + out.push_str(&class(chars.get(i..i + end).unwrap_or(&[]))?); + i += end + 1; + } + '\\' => match chars.get(i) { + Some(d) if d.is_ascii_digit() => return Err("back-references (\\1) work in Replace with, not in Find what".into()), + Some(&n) => { + out.push_str(&lit(n)); + i += 1; + } + None => out.push_str(r"\\"), + }, + '^' => { + let Some(&n) = chars.get(i) else { + out.push_str(r"\^"); + continue; + }; + if let Some(code) = find_code(n) { + spans |= matches!(n, 'p' | 'b'); + out.push_str(code); + i += 1; + } else if let Some((ch, used)) = numeric_code(&chars, i) { + spans |= ch == PARA_MARK; + out.push_str(&lit(ch)); + i += used; + } else { + out.push_str(r"\^"); + } + } + c => out.push_str(&lit(c)), + } + } + Ok((out, spans)) +} + +/// `{n}`, `{n,}`, `{n,m}` (and `{,m}`). +fn count(body: &str) -> Result { + let bad = || format!("{{{body}}} is not a valid count"); + let num = |s: &str| -> Result, String> { + let s = s.trim(); + if s.is_empty() { + return Ok(None); + } + let n: u32 = s.parse().map_err(|_| bad())?; + if n > MAX_COUNT { + return Err(format!("counts go up to {MAX_COUNT}")); + } + Ok(Some(n)) + }; + match body.split_once([',', ';']) { + None => Ok(format!("{{{}}}", num(body)?.ok_or_else(bad)?)), + Some((a, b)) => { + let a = num(a)?.unwrap_or(0); + match num(b)? { + Some(b) if b < a => Err(bad()), + Some(b) => Ok(format!("{{{a},{b}}}")), + None => Ok(format!("{{{a},}}")), + } + } + } +} + +/// `[abc]`, `[a-z]`, `[!x]`: the characters inside are literal. +fn class(inner: &[char]) -> Result { + let (neg, inner) = match inner.split_first() { + Some(('!', rest)) => (true, rest), + _ => (false, inner), + }; + if inner.is_empty() { + return Err("[ ] needs at least one character".into()); + } + let mut out = String::from(if neg { r"[^\r\x{1D}" } else { "[" }); + let mut i = 0; + while let Some(&c) = inner.get(i) { + let c = if c == '\\' && i + 1 < inner.len() { + i += 1; + inner.get(i).copied().unwrap_or(c) + } else { + c + }; + i += 1; + if inner.get(i) == Some(&'-') + && let Some(&hi) = inner.get(i + 1) + { + if hi < c { + return Err(format!("[{c}-{hi}] is not in ascending order")); + } + out.push_str(&format!("{}-{}", lit(c), lit(hi))); + i += 2; + } else { + out.push_str(&lit(c)); + } + } + out.push(']'); + Ok(out) +} + +/// A piece of Replace with text. +#[derive(Clone, Debug, PartialEq)] +pub enum Piece { + Lit(String), + /// `^&`: the text found. + Found, + /// `\n` with wildcards: group `n`. + Group(usize), + /// `^c`: the clipboard. + Clip, +} + +/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards. +pub fn parse_replace(with: &str, o: &Opts) -> Vec { + if o.literal || o.regex { + return vec![Piece::Lit(with.to_string())]; + } + let chars: Vec = with.chars().collect(); + let mut out = Vec::new(); + let mut cur = String::new(); + let flush = |cur: &mut String, out: &mut Vec| { + if !cur.is_empty() { + out.push(Piece::Lit(std::mem::take(cur))); + } + }; + let mut i = 0; + while let Some(&c) = chars.get(i) { + i += 1; + if c == '\\' && o.wildcards { + match chars.get(i) { + Some(d) if d.is_ascii_digit() && *d != '0' => { + flush(&mut cur, &mut out); + out.push(Piece::Group(d.to_digit(10).unwrap_or(0) as usize)); + i += 1; + continue; + } + Some(&n) => { + cur.push(n); + i += 1; + continue; + } + None => {} + } + } + if c == '^' + && let Some(&n) = chars.get(i) + { + if n == '&' || n == 'c' { + flush(&mut cur, &mut out); + out.push(if n == '&' { Piece::Found } else { Piece::Clip }); + i += 1; + continue; + } + if let Some(ch) = replace_code(n) { + cur.push(ch); + i += 1; + continue; + } + if let Some((ch, used)) = numeric_code(&chars, i) { + cur.push(ch); + i += used; + continue; + } + } + cur.push(c); + } + flush(&mut cur, &mut out); + out +} + +/// Searched text as it is inserted again: marks become paragraph breaks. +fn as_inserted(s: &str) -> String { + s.replace(SECT_MARK, "\r") +} + +/// Fill in Replace with text for one match: `found` is the matched text, `group(n)` a group's. +pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option, clip: &str) -> String { + let mut out = String::new(); + for p in pieces { + match p { + Piece::Lit(t) => out.push_str(t), + Piece::Found => out.push_str(&as_inserted(found)), + Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())), + Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")), + } + } + out +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index cd3c14e..5bc81a5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod edit; pub mod equation; pub mod fields; pub mod file; +pub mod find_pattern; pub mod fmt_revisions; pub mod format; pub mod index; diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index fedfb48..71241a0 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::numbering::ListKind; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop}; use wordcraft_doc::styles::{Style, StyleKind}; -use wordcraft_doc::{Block, Pos}; +use wordcraft_doc::{Block, CharProps, Pos}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -854,8 +854,7 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult { fn modify_style(s: &mut Session, v: &Value) -> CmdResult { let id = style_id(s, p::req_str(v, "style")?)?; - let chr: Option = - v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; + let chr: Option = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; let para: Option = v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; let next = p::str(v, "next").map(str::to_string); @@ -878,36 +877,108 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Update Style to Match Selection: the style takes on how the selection looks — its paragraph +/// style, character style and direct formatting, read at the start of the selection — so every +/// paragraph using the style changes. A paragraph style is then applied to the selected +/// paragraphs, whose direct formatting the style now carries is dropped (other direct formatting, +/// a bold word say, stays). The style keeps its outline level and list numbering. fn update_to_match(s: &mut Session, v: &Value) -> CmdResult { - let f = s.sel.focus.clone(); - let Some(para) = s.doc.para_at(&f).cloned() else { return sel_result(s) }; + let (a, b) = s.sel.ordered(); + let Some(para) = s.doc.para_at(&a).cloned() else { return sel_result(s) }; let id = match p::str(v, "style") { Some(n) => style_id(s, n)?, None => para.props.style.clone().unwrap_or_else(|| "Normal".into()), }; - let mut chr = para.props_at(f.off).clone(); - chr.style = None; - chr.link = None; - chr.ins = None; - chr.del = None; - chr.fmt_change = None; - let mut pp = para.props.formatting(); - pp.style = None; - pp.numbering = None; - if let Some(st) = s.doc.styles.get_mut(&id) { - st.chr.overlay(&chr); - st.para.overlay(&pp); + let sheet = &s.doc.styles; + let Some(target) = sheet.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) }; + if !matches!(target.kind, StyleKind::Paragraph | StyleKind::Character) { + return Err(CmdError::Failed(format!("`{}` can't be updated to match the selection", target.name))); } - // Remove the now-redundant direct formatting from the paragraph. - let p = s.doc.para_mut(f.story, &f.path)?; - let len = p.len(); - p.format(0, len, &|c| *c = c.cleared())?; - let style = p.props.style.clone(); - p.props = ParaProps { style, numbering: p.props.numbering, ..Default::default() }; + let direct = if s.sel.is_collapsed() { para.props_at(a.off) } else { para.props_of_char(a.off) }.clone(); + let para_style = para.props.style.clone().unwrap_or_else(|| "Normal".into()); + // How the selection looks: document defaults, its paragraph style chain, its character + // style chain, direct formatting (the resolve order, before defaults are filled in). + let (mut want_p, mut want_c) = sheet.para_style_props(¶_style); + let char_chain = |c: &mut CharProps, style: Option<&str>| { + for st in style.map(|cs| sheet.chain(cs)).unwrap_or_default() { + if matches!(st.kind, StyleKind::Character | StyleKind::Paragraph) { + c.overlay(&st.chr); + } + } + }; + char_chain(&mut want_c, direct.style.as_deref()); + want_c.overlay(&CharProps { style: None, ..direct.formatting() }); + want_p.overlay(&ParaProps { style: None, numbering: None, ..para.props.formatting() }); + // How the style looks now, in the same terms. + let (have_p, have_c) = match target.kind { + StyleKind::Character => { + let (pp, mut cc) = sheet.para_style_props(¶_style); + char_chain(&mut cc, Some(&id)); + (pp, cc) + } + _ => sheet.para_style_props(&id), + }; + let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?; + let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() }; + if let Some(st) = s.doc.styles.get_mut(&id) { + st.chr = chr; + st.para = pp; + } + if target.kind == StyleKind::Paragraph { + // The selected paragraphs take the style; what it now gives them needn't stay direct. + let paths = s.doc.paths_between(&a, &b); + for path in paths { + let p = s.doc.para_mut(a.story, &path)?; + p.props.style = Some(id.clone()); + p.props = strip_matching(&p.props, &want_p, PARA_KEEP); + let len = p.len(); + p.format(0, len, &|c| *c = strip_matching(c, &want_c, CHAR_KEEP))?; + p.mark = strip_matching(&p.mark, &want_c, CHAR_KEEP); + } + } + s.pending = None; touch_all(s); sel_result(s) } +/// Character properties Update to Match leaves alone: the character style, links and revisions. +const CHAR_KEEP: &[&str] = &["style", "link", "ins", "del", "fmtChange"]; +/// Paragraph properties Update to Match leaves alone: the style, its outline level (a heading +/// stays a heading), list numbering and revisions. +const PARA_KEEP: &[&str] = &["style", "outlineLevel", "numbering", "numChange", "fmtChange"]; + +fn props_map(t: &T) -> serde_json::Map { + match serde_json::to_value(t) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + } +} + +/// A style's own properties `own`, changed so that its chain gives `want` where it gave `have`: +/// a property that differs is set to the wanted value, or dropped (inherited again) when the +/// selection doesn't set it at all. +fn retarget(own: &T, have: &T, want: &T, keep: &[&str]) -> Result { + let (mut out, have, want) = (props_map(own), props_map(have), props_map(want)); + let keys: std::collections::BTreeSet<&String> = have.keys().chain(want.keys()).collect(); + for k in keys { + if keep.contains(&k.as_str()) || have.get(k) == want.get(k) { + continue; + } + match want.get(k) { + Some(v) => out.insert(k.clone(), v.clone()), + None => out.remove(k), + }; + } + serde_json::from_value(Value::Object(out)).map_err(|e| CmdError::Failed(e.to_string())) +} + +/// `direct` without the properties that have the same value in `inherited` (outside `keep`). +fn strip_matching(direct: &T, inherited: &T, keep: &[&str]) -> T { + let (mut out, inherited) = (props_map(direct), props_map(inherited)); + out.retain(|k, v| keep.contains(&k.as_str()) || inherited.get(k) != Some(v)); + serde_json::from_value(Value::Object(out)).unwrap_or_else(|_| direct.clone()) +} + /// Delete a custom style. Text that uses it falls back to the style it was based on (or the /// default style), styles based on it are rebased, and a custom character style linked to it /// goes with it. Built-in styles are refused. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 80368ef..1f98d19 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -3,7 +3,7 @@ use serde_json::Value; use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_HYPHEN}; use wordcraft_doc::props::{Border, BorderStyle, NumRef}; -use wordcraft_doc::{Block, ListKind, Pos}; +use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; use super::{delete_selection, non_mac, sel_result, split_para, type_text}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -119,7 +119,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// "* " / "- " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. +/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. fn list_autoformat(s: &mut Session) -> Result { let f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; @@ -127,8 +127,12 @@ fn list_autoformat(s: &mut Session) -> Result { return Ok(false); } let kind = match para.text.as_str() { - "* " | "> " => ListKind::Bullet, + "* " => ListKind::Bullet, "- " => ListKind::BulletChar('-'), + // Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows). + "> " => ListKind::BulletChar('\u{27A2}'), + "-> " => ListKind::BulletChar('\u{2794}'), + "=> " => ListKind::BulletChar('\u{21E8}'), "1. " => ListKind::Numbered, "1) " => ListKind::NumberedParen, "a. " => ListKind::LowerLetterDot, @@ -193,12 +197,19 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { Ok(()) } -/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###" turns the -/// characters into a bottom border on the paragraph above (or this one at the top of a story). -fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { - let Some(para) = s.doc.para_at(at) else { return Ok(false) }; +/// An AutoFormat that Enter applies to the paragraph just finished. +enum EnterAutoFormat { + /// "---" and friends: a bottom border. + Border(Border), + /// "+---+-----+": a one-row table; the dash count of each column. + Table(Vec), +} + +/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". +fn border_line(s: &Session, at: &Pos) -> Option { + let para = s.doc.para_at(at)?; let text = para.text.as_str(); - let Some(c) = text.chars().next() else { return Ok(false) }; + let c = text.chars().next()?; if !s.prefs.autocorrect.enabled || !s.prefs.autocorrect.border_lines || at.off != text.len() @@ -206,7 +217,7 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { || !text.chars().all(|x| x == c) || para.props.numbering.is_some_and(|n| n.num != 0) { - return Ok(false); + return None; } let (style, width) = match c { '-' => (BorderStyle::Single, 0.75), @@ -215,9 +226,39 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { '*' => (BorderStyle::Dotted, 2.25), '~' => (BorderStyle::Wave, 0.75), '#' => (BorderStyle::Triple, 0.75), - _ => return Ok(false), + _ => return None, }; - let line = Border { style, width, color: None, space: 1.0 }; + Some(Border { style, width, color: None, space: 1.0 }) +} + +/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs +/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold +/// leave the text alone. +fn table_line(s: &Session, at: &Pos) -> Option> { + let para = s.doc.para_at(at)?; + let text = para.text.as_str(); + if !s.prefs.autocorrect.enabled + || at.story != StoryRef::Body + || at.path.0.len() != 1 + || at.off != text.len() + || para.props.numbering.is_some_and(|n| n.num != 0) + { + return None; + } + let inner = text.strip_prefix('+')?.strip_suffix('+')?; + let mut cols = Vec::new(); + for run in inner.split('+') { + if run.is_empty() || !run.bytes().all(|b| b == b'-') || cols.len() >= wordcraft_doc::table::MAX_COLS { + return None; + } + cols.push(run.len()); + } + Some(cols) +} + +/// Turn the border-line characters into a bottom border on the paragraph above (or this one +/// at the top of a story). +fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> { let set_bottom = |p: &mut wordcraft_doc::Paragraph| { let mut b = p.props.borders.unwrap_or_default(); b.bottom = Some(line); @@ -226,8 +267,8 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { }; let i = at.path.last(); let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None }; - let len = text.len(); let cur = s.doc.para_mut(at.story, &at.path)?; + let len = cur.len(); cur.delete(0, len)?; let start = Pos { off: 0, ..at.clone() }; match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) { @@ -245,8 +286,34 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { s.sel = Selection::caret(new); } } - s.goal_x = None; - Ok(true) + Ok(()) +} + +/// Replace the "+---+" characters with a one-row table across the text width, each column as +/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret +/// goes into the first cell. +fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> { + let total = cols.iter().sum::().max(1) as f32; + let block = at.path.0.first().copied().unwrap_or(0) as usize; + let width = s.doc.sections().get(s.doc.section_index_of(block)).map(|(_, sp)| sp.text_width()).unwrap_or(468.0).max(36.0); + let mut t = Table::new(1, cols.len(), width); + for (i, d) in cols.iter().enumerate() { + let w = (width * *d as f32 / total).max(6.0); + if let Some(g) = t.grid.get_mut(i) { + *g = w; + } + if let Some(c) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(i)) { + c.props.width = Some(w); + } + } + let p = s.doc.para_mut(at.story, &at.path)?; + let len = p.len(); + p.delete(0, len)?; + s.doc.insert_block(at.story, &at.path, Block::Table(t))?; + let mut first = at.path.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story: at.story, path: Path(first), off: 0 }); + Ok(()) } /// Toggle Unicode Character (Alt+X): the hex code before the caret (up to six digits, `U+` @@ -325,7 +392,21 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { } super::controls::prepare_edit(s)?; let mut at = delete_selection(s)?; - if border_line_autoformat(s, &at)? { + let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table)); + if let Some(auto) = auto { + // Word: Undo right after reverts only the AutoFormat, so the typed characters come back + // and the Enter stays. Record that plain Enter as its own undo step. + let (doc, sel) = (s.doc.clone(), s.sel.clone()); + let new = split_para(s, &at)?; + s.sel = Selection::caret(new); + let plain_doc = std::mem::replace(&mut s.doc, doc); + let plain_sel = std::mem::replace(&mut s.sel, sel); + match auto { + EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?, + EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?, + } + s.goal_x = None; + s.push_undo("AutoFormat", plain_doc, plain_sel); return sel_result(s); } // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 9a37eca..6ad3fc4 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -12,6 +12,9 @@ use wordcraft_doc::{Block, Document, Paragraph, Pos, RevisionKind, StoryRef, par use super::{delete_selection, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// Smileys replaced even when typed straight after a word ("ok:)"), as Word does. +const SMILEYS: &[&str] = &[":-)", ":-(", ":)", ":(", ":|"]; + /// Built-in AutoCorrect replacements (our own list of common typos and symbols). pub const AUTOCORRECT: &[(&str, &str)] = &[ ("teh", "the"), @@ -48,6 +51,10 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("<-", "←"), ("=>", "⇒"), (":)", "☺"), + (":-)", "☺"), + (":(", "☹"), + (":-(", "☹"), + (":|", "\u{1F610}"), ("1/2", "½"), ("1/4", "¼"), ("3/4", "¾"), @@ -725,6 +732,11 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> let (from, rep) = match found { Some(r) => (start, Some(r)), None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())), + None if ac.replace_text => match SMILEYS.iter().find(|m| word.len() > m.len() && word.ends_with(**m)) { + // "ok:)" → "ok☺": only the smiley at the end of the word is replaced. + Some(m) => (start + word.len() - m.len(), AUTOCORRECT.iter().find(|(a, _)| a == m).map(|(_, b)| b.to_string())), + None => (start, None), + }, None => (start, None), }; let mut caret = f.off; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 602f9dc..bf2a51c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -151,6 +151,14 @@ pub struct FindState { pub match_case: bool, pub whole_word: bool, pub regex: bool, + /// Word's "Use wildcards" (#543). + pub wildcards: bool, + /// Take Find what literally: no `^` codes (`special: false`). + pub literal: bool, + pub match_prefix: bool, + pub match_suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, /// Matches from the last search: (start, end). #[serde(skip)] pub results: Vec<(Pos, Pos)>, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d832747..a77dc6b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -497,6 +497,75 @@ fn find_replace() { assert_eq!(r["count"], 4); } +#[test] +fn find_replace_special_codes() { + // ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"})); + let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len(); + assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1); + assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2)); + run(&mut s, "edit.undo", json!({})); + assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3)); + run(&mut s, "document.setText", json!({"text": "a, b, c"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2); + assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3)); + run(&mut s, "edit.undo", json!({})); + assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1)); + // ^t, ^# and ^& (the found text), ^^ is a caret. + run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1); + assert_eq!(text(&s), "x y <1><2> a-b"); + // `special: false` reads the text literally. + assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0); +} + +#[test] +fn find_replace_wildcards() { + let mut s = s(); + // Groups and back-references swap words; Use wildcards matches case. + run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"})); + let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true})); + assert_eq!(r["replaced"], 2); + assert_eq!(text(&s), "John Smith and Jane Doe; smith, x"); + // < and > anchor to word starts and ends; * is the shortest run; {n} counts. + run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"})); + assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1); + assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0); + assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1); + // Options of the normal search: prefix, suffix, ignore punctuation. + assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2); + assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2); + run(&mut s, "document.setText", json!({"text": "e.g. eg"})); + assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2); +} + +#[test] +fn hostile_find_patterns_are_bounded() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)})); + // Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine). + let t = std::time::Instant::now(); + for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] { + let _ = s.run("edit.find", &json!({"text": q, "wildcards": true})); + } + assert!(t.elapsed().as_secs() < 20); + // Bad and oversized patterns are errors, not crashes. + for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] { + assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}"); + } + assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err()); + for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] { + let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false})); + } +} + #[test] fn replace_under_track_changes_skips_deleted_text() { // Replace marks the match deleted and leaves it in the text; the next @@ -2049,6 +2118,49 @@ fn styles_apply_multi_paragraph_selection() { } } +/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2 +/// look like the selected text — its paragraph style and direct formatting — so every Heading 2 +/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step. +#[test] +fn update_heading_to_match_selection_takes_the_selections_formatting() { + use wordcraft_doc::{Align, CharProps, ParaProps, Rgb, TextColor}; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Heading text"})); + run(&mut s, "styles.apply", json!({"style": "Heading 2"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Body text"})); + run(&mut s, "styles.apply", json!({"style": "Normal"})); + run(&mut s, "select.text", json!({"text": "Body text"})); + run(&mut s, "format.size", json!({"size": 20})); + run(&mut s, "format.italic", json!({})); + run(&mut s, "format.color", json!({"color": "C00000"})); + run(&mut s, "para.alignCenter", json!({})); + let normal = s.doc.styles.resolve_char(Some("Normal"), &CharProps::default()); + let before = s.undo_depth(); + + run(&mut s, "styles.updateToMatch", json!({"style": "Heading2"})); + + let h2 = ParaProps { style: Some("Heading2".into()), ..Default::default() }; + let c = s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default()); + assert_eq!(c.size, 20.0); + assert!(c.italic); + assert_eq!(c.color, TextColor::Rgb(Rgb(0xC0, 0, 0))); + assert_eq!(c.font, normal.font, "the heading font gives way to the selection's"); + let p = s.doc.styles.resolve_para(&h2); + assert_eq!(p.align, Align::Center); + assert_eq!(p.outline_level, Some(1), "Heading 2 stays a level-2 heading"); + // The selected paragraph now carries Heading 2, its direct formatting absorbed by the style. + assert_eq!(para_style(&s, 1).as_deref(), Some("Heading2")); + let body = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert!(body.props_of_char(0).formatting().is_empty(), "{:?}", body.props_of_char(0)); + assert_eq!(body.props.align, None); + assert_eq!(s.undo_depth(), before + 1, "one undo step"); + + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default()).size, 16.0); + assert_eq!(para_style(&s, 1).as_deref(), Some("Normal")); +} + #[test] fn styles_apply_rejected_on_protected_document() { let mut s = s(); diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 52cd8ca..c9ed608 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -257,6 +257,59 @@ fn border_line_autoformat() { assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); } +#[test] +fn undo_right_after_a_border_line_brings_the_characters_back() { + let mut s = typed("Above\n---\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the characters are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); +} + +#[test] +fn plus_and_hyphen_line_becomes_a_table() { + let mut s = typed("+--+------+\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows.len(), 1); + assert_eq!(t.grid.len(), 2); + assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); + // Undo right after brings the typed characters back, the Enter stays. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); + // Not a table: text around it, or more columns than a table can hold. + let s = typed("x +--+--+\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); + let s = typed(&format!("+{}\n", "-+".repeat(64))); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); +} + +#[test] +fn arrow_bullets() { + for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { + let s = typed(&format!("{keys}item\nnext")); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); + assert_eq!(label_at(&s, 0), bullet, "{keys}"); + } + // An arrow list doesn't join a plain bullet list. + let s = typed("* a\n\n> b"); + assert_eq!(label_at(&s, 0), "\u{2022}"); + assert_eq!(label_at(&s, 1), "\u{27A2}"); +} + +#[test] +fn smileys_are_replaced_inside_words_too() { + let s = typed("so ok:) sad:( meh:| :) fine:-) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); +} + #[test] fn a_large_mixed_document_types_like_word() { // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 00dbe20..0de5de5 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -292,6 +292,13 @@ pub struct FontDb { /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: 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"))] + coverage: Mutex, + /// Counts the font folder scans, so the coverage index notices a rescan. + #[cfg(not(target_arch = "wasm32"))] + catalog_gen: AtomicU32, } #[cfg(not(target_arch = "wasm32"))] @@ -299,6 +306,115 @@ pub struct FontDb { struct SysFallback { enabled: bool, misses: std::collections::HashSet, + /// Preferred fallback families already loaded (or tried) by a fallback search. + tried: std::collections::HashSet<&'static str>, +} + +/// Characters remembered as covered by no installed font (a cap on memory: once full the set +/// starts over, and the coverage index answers again quickly). +#[cfg(not(target_arch = "wasm32"))] +const SYS_MISSES_MAX: usize = 65_536; + +/// Installed font files larger than this are not searched for fallback characters. +#[cfg(not(target_arch = "wasm32"))] +const SYS_FALLBACK_MAX_FILE: u64 = 40 << 20; + +/// Which characters each cataloged font file maps (#605). Before it, every distinct character no +/// loaded font covered read and parsed every installed font file again: 500 distinct Tangut +/// characters froze the app for most of a minute. Now each file is read once, when a search +/// first gets to it, and later searches answer from its ranges, reading only a file that matches. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct CoverageIndex { + /// The catalog scan the file list was taken from (`None` until the first search). + generation: Option, + /// The cataloged font files, sorted. + files: Vec, + /// Font files read by fallback searches (each at most once to index it, once more to load it). + reads: usize, +} + +#[cfg(not(target_arch = "wasm32"))] +struct IndexedFile { + path: std::path::PathBuf, + /// The code points the file's faces map, as sorted, disjoint inclusive ranges (`None` until + /// the file is read; empty when it can't be read or is too large). + ranges: Option>, + /// Loaded for a character it covers: its faces are searched as loaded fonts from then on. + used: bool, +} + +/// Does `ranges` (sorted, disjoint, inclusive) hold `cp`? +#[cfg(not(target_arch = "wasm32"))] +fn ranges_hold(ranges: &[(u32, u32)], cp: u32) -> bool { + let i = ranges.partition_point(|r| r.1 < cp); + ranges.get(i).is_some_and(|r| r.0 <= cp) +} + +/// The code points mapped by the faces of `data` (a font file or collection), as sorted, +/// disjoint, inclusive ranges. Bounded however damaged the file: at most 256 faces, a budget of +/// mappings read across them, and a cap on the ranges kept. +#[cfg(not(target_arch = "wasm32"))] +fn coverage_ranges(data: &[u8]) -> Box<[(u32, u32)]> { + const MAX_FACES: u32 = 256; + const MAX_MAPPINGS: usize = 4 * 0x11_0000; + const MAX_RANGES: usize = 1 << 17; + fn compact(r: &mut Vec<(u32, u32)>) { + r.sort_unstable(); + let mut out: Vec<(u32, u32)> = Vec::with_capacity(r.len()); + for &(s, e) in r.iter() { + match out.last_mut() { + Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e), + _ => out.push((s, e)), + } + } + *r = out; + } + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES), + Err(_) => 0, + }; + let mut ranges: Vec<(u32, u32)> = Vec::new(); + let mut budget = MAX_MAPPINGS; + 'faces: for i in 0..count { + let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; + for (cp, _) in f.charmap().mappings() { + budget = match budget.checked_sub(1) { + Some(b) => b, + None => break 'faces, + }; + match ranges.last_mut() { + Some(last) if last.1.checked_add(1) == Some(cp) => last.1 = cp, + Some(last) if last.0 <= cp && cp <= last.1 => {} + _ => { + if ranges.len() >= MAX_RANGES { + compact(&mut ranges); + if ranges.len() >= MAX_RANGES { + break 'faces; + } + } + ranges.push((cp, cp)); + } + } + } + } + compact(&mut ranges); + ranges.into_boxed_slice() +} + +/// An installed font file's bytes, `None` when it can't be read or is larger than +/// [`SYS_FALLBACK_MAX_FILE`] (also when it grew past that since it was cataloged). +#[cfg(not(target_arch = "wasm32"))] +fn read_fallback_file(path: &std::path::Path) -> Option> { + use std::io::Read; + let file = std::fs::File::open(path).ok()?; + if file.metadata().ok()?.len() > SYS_FALLBACK_MAX_FILE { + return None; + } + let mut data = Vec::new(); + file.take(SYS_FALLBACK_MAX_FILE + 1).read_to_end(&mut data).ok()?; + (data.len() as u64 <= SYS_FALLBACK_MAX_FILE).then_some(data) } /// Families preferred for right-to-left scripts (Persian, Arabic, Urdu, Hebrew) when the @@ -705,6 +821,10 @@ impl FontDb { loading: Mutex::new(()), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + #[cfg(not(target_arch = "wasm32"))] + coverage: Mutex::new(CoverageIndex::default()), + #[cfg(not(target_arch = "wasm32"))] + catalog_gen: AtomicU32::new(0), } } @@ -927,6 +1047,7 @@ impl FontDb { let n = found.len(); log::debug!("cataloged {n} system font faces"); *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + self.catalog_gen.fetch_add(1, Ordering::AcqRel); n } @@ -1157,7 +1278,8 @@ impl FontDb { } /// Load a system font covering `c` (preferred fallback families first, then any cataloged - /// file under 40 MB). Returns true if one was loaded. Misses are remembered. + /// file under 40 MB, found through the coverage index). Returns true if one was loaded. + /// Misses are remembered. #[cfg(not(target_arch = "wasm32"))] fn system_fallback(&self, c: char) -> bool { if c.is_control() || c.is_whitespace() { @@ -1174,27 +1296,76 @@ impl FontDb { let rtl = crate::is_rtl(c) || arabic_block(c); let preferred = RTL_FALLBACKS.iter().filter(|_| rtl); for fam in preferred.chain(SYSTEM_FALLBACKS.iter()) { - if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); - if covered(self) { - return true; - } - } - } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); - paths.sort(); - paths.dedup(); - for p in paths { - if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + // 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; } - let Ok(data) = std::fs::read(&p) else { continue }; - let hit = enumerate_faces(&data).iter().any(|f| skrifa::FontRef::from_index(&data, f.index).is_ok_and(|f| f.charmap().map(c).is_some())); - if hit && self.add_font(data) > 0 && covered(self) { + if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).tried.insert(fam) { + continue; + } + self.load_cataloged(fam); + if covered(self) { + return true; + } + } + if self.indexed_fallback(c) { + return true; + } + let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if sys.misses.len() >= SYS_MISSES_MAX { + sys.misses.clear(); + } + sys.misses.insert(c); + false + } + + /// Load the first cataloged font file (in path order) that maps `c`, reading each file not + /// yet indexed as the search reaches it. Returns whether a loaded face now covers `c`. + #[cfg(not(target_arch = "wasm32"))] + fn indexed_fallback(&self, c: char) -> bool { + let cp = u32::from(c); + let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner()); + let generation = self.catalog_gen.load(Ordering::Acquire); + if index.generation != Some(generation) { + // A new scan (or the first search): keep what is known of files still installed. + let mut known: HashMap = + std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect(); + let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + index.files = paths.into_iter().map(|path| known.remove(&path).unwrap_or(IndexedFile { path, ranges: None, used: false })).collect(); + index.generation = Some(generation); + } + let CoverageIndex { files, reads, .. } = &mut *index; + for file in files.iter_mut().filter(|f| !f.used) { + let mut data = None; + match &file.ranges { + Some(r) if !ranges_hold(r, cp) => continue, + Some(_) => {} + None => { + *reads += 1; + data = read_fallback_file(&file.path); + let ranges = data.as_deref().map(coverage_ranges).unwrap_or_default(); + let hit = ranges_hold(&ranges, cp); + file.ranges = Some(ranges); + if !hit { + continue; + } + } + } + // The file maps `c`: load it (once; its faces are loaded fonts from now on). + file.used = true; + if data.is_none() { + *reads += 1; + data = read_fallback_file(&file.path); + } + if let Some(data) = data + && self.add_font(data) > 0 + && self.read_faces().iter().any(|f| f.covers(c)) + { return true; } } - self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); false } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 7826504..7cdbb23 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -250,6 +250,52 @@ fn fallback_cache_is_dropped_when_a_face_loads_by_its_legacy_family_name() { assert!(!cache.map.contains_key(&(thai, latin.id())), "answers from before the load are dropped"); } +#[test] +fn fallback_reads_each_installed_font_file_once_however_many_characters_miss() { + // #605: every distinct character no font covers read every installed font file again. + let dir = font_dir("coverage"); + // The installed Regular also maps U+30000 (no bundled font does): its last cmap group, the + // single character U+1F916, moved there (still the highest, so the groups stay sorted). + let mut regular = renamed("SourceSans3-Regular.ttf"); + let (from, to) = ([0x0001_F916_u32.to_be_bytes(); 2].concat(), [0x0003_0000_u32.to_be_bytes(); 2].concat()); + let mut patched = 0; + while let Some(at) = regular.windows(from.len()).position(|w| w == from) { + regular[at..at + to.len()].copy_from_slice(&to); + patched += 1; + } + assert!(patched > 0); + std::fs::write(dir.join("Sysfont-Regular.ttf"), regular).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let latin = db.face(FALLBACK_FAMILY, "Regular"); + let found = db.fallback_for('\u{30000}', latin.id()).expect("the installed font maps it"); + assert_eq!((found.family.as_str(), found.style.as_str()), (FAMILY, "Regular")); + // 2,000 distinct characters nothing maps: the index answers, no file is read again. + for cp in 0x30001..0x30001 + 2000 { + let c = char::from_u32(cp).unwrap(); + assert!(db.fallback_for(c, latin.id()).is_none()); + } + let index = db.coverage.lock().unwrap(); + assert_eq!(index.files.len(), 2, "Regular and Bold are cataloged"); + assert_eq!(index.reads, 2, "each file read once"); +} + +#[test] +fn coverage_ranges_hold_exactly_the_mapped_characters() { + let data = renamed("SourceSans3-Regular.ttf"); + let ranges = coverage_ranges(&data); + assert!(ranges.windows(2).all(|w| w[0].1 + 1 < w[1].0), "sorted, disjoint and merged"); + let font = skrifa::FontRef::new(&data).unwrap(); + let mapped: Vec = font.charmap().mappings().map(|(cp, _)| cp).collect(); + assert!(ranges.len() < mapped.len() / 4, "ranges, not a set of characters"); + assert!(mapped.iter().all(|cp| ranges_hold(&ranges, *cp))); + for cp in [0x0, 0x9, 0x0E01, 0x17000, 0x10_FFFF] { + assert_eq!(ranges_hold(&ranges, cp), font.charmap().map(cp).is_some(), "U+{cp:04X}"); + } + // Damaged data indexes as nothing. + assert!(coverage_ranges(b"ttcf\0\x01\0\0\xff\xff\xff\xff").is_empty()); + assert!(coverage_ranges(&data[..100]).is_empty()); +} + /// A bundled font renamed by a `name` table holding just its family names: (Windows language id, /// name) pairs, English (0x0409) first, as SimSun's names 宋体 for Simplified Chinese. fn with_family_names(file: &str, names: &[(u16, &str)]) -> Vec { diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f20c9ac..c38c2d7 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -651,21 +651,24 @@ fn lines( // Label. if li == 0 && let Some(lab) = &pl.label - && let Some(st) = pl.styles.get(lab.style as usize) { let gx = x + lab.x; - out.push(Draw::Glyphs { - face: st.face, - size: st.size, - glyphs: lab.glyphs.iter().map(|g| (g.gid, gx + g.dx, base - st.shift - g.dy)).collect(), - color: text_color(&st.rc.color, None), - alpha, - synth_bold: st.synth_bold, - synth_italic: st.synth_italic, - text: lab.text.clone(), - link: None, - ranges: Vec::new(), - }); + // One draw per font run: a glyph id only means something in its own face. + for run in &lab.runs { + let Some(st) = pl.styles.get(run.style as usize) else { continue }; + out.push(Draw::Glyphs { + face: st.face, + size: st.size, + glyphs: lab.glyphs.get(run.glyphs.clone()).into_iter().flatten().map(|g| (g.gid, gx + g.dx, base - st.shift - g.dy)).collect(), + color: text_color(&st.rc.color, None), + alpha, + synth_bold: st.synth_bold, + synth_italic: st.synth_italic, + text: run.text.clone(), + link: None, + ranges: Vec::new(), + }); + } } // Hyphen at a hyphenated line end. if let Some((si, gid, adv)) = line.hyphen diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index f202391..b2d516f 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -69,6 +69,10 @@ pub fn format_switch(instr: &str) -> Option { "OrdText" | "ordtext" => NumFormat::OrdinalText, "Ordinal" | "ordinal" => NumFormat::Ordinal, "Arabic" | "arabic" => NumFormat::Decimal, + // Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一). + v if v.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand, + v if v.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified, + v if v.eq_ignore_ascii_case("ChineseNum3") => NumFormat::IdeographDigital, _ => continue, }); } @@ -110,6 +114,8 @@ mod tests { assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true)); assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV"); assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve"); + assert_eq!(field_text("NUMPAGES \\* CHINESENUM1", "", &ctx).0, "十二"); + assert_eq!(field_text("PAGE \\* ChineseNum2", "", &ctx).0, "肆"); assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false)); assert_eq!(field_name("=SUM(ABOVE)"), "SUM(ABOVE)"); assert_eq!(field_code_text(" PAGE \\* ROMAN "), "{ PAGE \\* ROMAN }"); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 43016f9..8e190be 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -215,8 +215,13 @@ impl Line { #[derive(Clone, Debug)] pub struct Label { pub text: String, + /// Style of the label's last run (the suffix space and the bidi tests measure with it). pub style: u16, pub glyphs: Vec, + /// Font runs over `glyphs`, in drawing order: font fallback can split a label ("1、" takes + /// the digit from the Latin face and the comma from a CJK one), and each glyph id only means + /// something in the face it was shaped with. + pub runs: Vec, pub x: f32, pub width: f32, } @@ -236,6 +241,17 @@ pub struct RubyLayout { pub raise: f32, } +/// One font run of a list label. +#[derive(Clone, Debug)] +pub struct LabelRun { + /// Index into `ParaLayout::styles`. + pub style: u16, + /// Glyph range in `Label::glyphs`. + pub glyphs: std::ops::Range, + /// The run's characters (for the PDF text layer). + pub text: String, +} + /// A laid-out paragraph. #[derive(Clone, Debug)] pub struct ParaLayout { @@ -1234,17 +1250,27 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect(); } let mut glyphs = Vec::new(); + let mut runs: Vec = Vec::new(); let mut x = 0.0; let mut style = mark_style; for c in &cl { style = c.style; + let g0 = glyphs.len(); for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { glyphs.push(Glyph { dx: g.dx + x, ..*g }); } + let chars = text.get(c.start..c.end).unwrap_or(""); + match runs.last_mut() { + Some(r) if r.style == c.style => { + r.glyphs.end = glyphs.len(); + r.text.push_str(chars); + } + _ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars.to_string() }), + } x += c.adv; } b.glyphs.truncate(before_g); - Label { text, style, glyphs, x: 0.0, width: x } + Label { text, style, glyphs, runs, x: 0.0, width: x } }); let mut pl = ParaLayout { @@ -1648,12 +1674,11 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio asc = st.ascent; desc = st.descent; } - if first - && let Some(lab) = &pl.label - && let Some(st) = pl.styles.get(lab.style as usize) - { - asc = asc.max(st.ascent); - desc = desc.max(st.descent); + if first && let Some(lab) = &pl.label { + for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) { + asc = asc.max(st.ascent); + desc = desc.max(st.descent); + } } // Line spacing scales the text's height, never a picture's: a line is at least as tall // as its tallest picture plus the descent below the baseline. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 72138cb..1fff79f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -412,6 +412,55 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn chinese_counting_list_labels_and_their_font_runs() { + // Issue #534: a "一、" list showed digits, drawn with glyph ids of another font. + use wordcraft_doc::section::NumFormat; + let mut d = Document::from_text("甲\n乙\n丙"); + let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::ChineseCounting; + l.text = "%1、".into(); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 3), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let labels = |d: &Document| -> Vec<(String, Vec<(String, bool)>)> { + let l = lay(d); + l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }) + .filter_map(|pl| { + let lab = pl.label.clone()?; + // Every run draws glyphs of its own face. + let runs = lab + .runs + .iter() + .map(|r| { + let face = &pl.styles[r.style as usize].face; + let gids: Vec = lab.glyphs[r.glyphs.clone()].iter().map(|g| g.gid).collect(); + (r.text.clone(), r.text.chars().all(|c| gids.contains(&face.glyph_for(c)))) + }) + .collect(); + Some((lab.text, runs)) + }) + .collect() + }; + let got = labels(&d); + assert_eq!(got.iter().map(|(t, _)| t.as_str()).collect::>(), ["一、", "二、", "三、"]); + // A decimal label next to a CJK comma mixes faces when the Latin face lacks the comma. + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::Decimal; + } + } + for (text, runs) in labels(&d).into_iter().chain(got) { + assert_eq!(runs.iter().map(|(t, _)| t.as_str()).collect::(), text); + assert!(runs.iter().all(|(_, own)| *own), "{text}: {runs:?}"); + } +} + #[test] fn symbol_font_bullet_without_the_font_draws_a_bullet() { // Word's default bullet: U+F0B7 in Symbol. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 08d2d31..496cc35 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,108 @@ use wordcraft_engine::Password; use crate::WordApp; use crate::theme::{Tokens, semibold}; +/// The Find and Replace dialog's search options beyond the first row (#543). +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FindExtra { + pub wildcards: bool, + pub prefix: bool, + pub suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// The search options are shown. + pub more: bool, + /// Special ▾ inserts into Replace with (it last had the focus), not Find what. + pub to_replace: bool, +} + +/// The Special ▾ menu: (name, code) for Find what, without and with wildcards. +const SPECIAL_FIND: &[(&str, &str)] = &[ + ("Paragraph Mark", "^p"), + ("Tab Character", "^t"), + ("Any Character", "^?"), + ("Any Digit", "^#"), + ("Any Letter", "^$"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Graphic", "^g"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), + ("Section Break", "^b"), + ("White Space", "^w"), +]; +const SPECIAL_FIND_WILD: &[(&str, &str)] = &[ + ("Any Character", "?"), + ("Character in Range", "[-]"), + ("Beginning of Word", "<"), + ("End of Word", ">"), + ("Expression", "()"), + ("Not", "[!]"), + ("Num Occurrences", "{,}"), + ("Previous 1 or More", "@"), + ("0 or More Characters", "*"), + ("Paragraph Mark", "^13"), + ("Tab Character", "^t"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Graphic", "^g"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), +]; +/// Replace with; with wildcards, "Find What Expression" (`\1`) comes first. +const SPECIAL_REPLACE: &[(&str, &str)] = &[ + ("Paragraph Mark", "^p"), + ("Tab Character", "^t"), + ("Caret Character", "^^"), + ("Section Character", "^%"), + ("Paragraph Character", "^v"), + ("Clipboard Contents", "^c"), + ("Column Break", "^n"), + ("Em Dash", "^+"), + ("En Dash", "^="), + ("Find What Text", "^&"), + ("Manual Line Break", "^l"), + ("Manual Page Break", "^m"), + ("Nonbreaking Hyphen", "^~"), + ("Nonbreaking Space", "^s"), + ("Optional Hyphen", "^-"), +]; + +/// The Special ▾ menu button; returns the code picked. +fn special_menu(ui: &mut Ui, extra: &FindExtra) -> Option<&'static str> { + let mut picked = None; + ui.menu_button(format!("{} ▾", tl!("Special")), |ui| { + let wild_replace: &[(&str, &str)] = &[("Find What Expression", "\\1")]; + let lists: [&[(&str, &str)]; 2] = match (extra.to_replace, extra.wildcards) { + (false, false) => [SPECIAL_FIND, &[]], + (false, true) => [SPECIAL_FIND_WILD, &[]], + (true, false) => [SPECIAL_REPLACE, &[]], + (true, true) => [wild_replace, SPECIAL_REPLACE], + }; + for (name, code) in lists.into_iter().flatten() { + if ui.button(format!("{} {code}", tl!(name))).clicked() { + picked = Some(*code); + ui.close(); + } + } + }); + picked +} + #[derive(Clone, Debug, Serialize)] #[serde(tag = "dialog", rename_all = "camelCase")] pub enum Dialog { @@ -84,6 +186,8 @@ pub enum Dialog { match_case: bool, whole_word: bool, regex: bool, + /// Search options under "Search Options" (#543). + extra: FindExtra, replace_mode: bool, message: String, }, @@ -869,6 +973,19 @@ impl Dialog { match_case: app.session.find.match_case, whole_word: app.session.find.whole_word, regex: app.session.find.regex, + extra: FindExtra { + wildcards: app.session.find.wildcards, + prefix: app.session.find.match_prefix, + suffix: app.session.find.match_suffix, + ignore_punct: app.session.find.ignore_punct, + ignore_space: app.session.find.ignore_space, + more: app.session.find.wildcards + || app.session.find.match_prefix + || app.session.find.match_suffix + || app.session.find.ignore_punct + || app.session.find.ignore_space, + to_replace: false, + }, replace_mode: name == "replace", message: String::new(), }, @@ -1462,7 +1579,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form), Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form), - Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => { + Dialog::Find { query, replace, match_case, whole_word, regex, extra, replace_mode, message } => { ui.horizontal(|ui| { if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() { *replace_mode = false; @@ -1473,20 +1590,59 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.horizontal(|ui| { ui.label(tl!("Find what:")); - ui.add(egui::TextEdit::singleline(query).desired_width(240.0)); + if ui.add(egui::TextEdit::singleline(query).desired_width(240.0)).gained_focus() { + extra.to_replace = false; + } }); if *replace_mode { ui.horizontal(|ui| { ui.label(tl!("Replace with:")); - ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)); + if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() { + extra.to_replace = true; + } + }); + } else { + extra.to_replace = false; + } + // Word greys out Match case and the word options with wildcards (they always match case). + let plain = !extra.wildcards; + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(match_case, tl!("Match case"))); + ui.add_enabled(plain, egui::Checkbox::new(whole_word, tl!("Whole words"))); + ui.checkbox(&mut extra.wildcards, tl!("Use wildcards")); + }); + let arrow = if extra.more { "▾" } else { "▸" }; + if ui.selectable_label(extra.more, format!("{arrow} {}", tl!("Search Options"))).clicked() { + extra.more = !extra.more; + } + if extra.more { + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.prefix, tl!("Match prefix"))); + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.suffix, tl!("Match suffix"))); + }); + ui.horizontal(|ui| { + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_punct, tl!("Ignore punctuation characters"))); + ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_space, tl!("Ignore white-space characters"))); + }); + ui.horizontal(|ui| { + ui.checkbox(regex, tl!("Regular expressions")); + if let Some(code) = special_menu(ui, extra) { + if extra.to_replace { replace.push_str(code) } else { query.push_str(code) } + } }); } - ui.horizontal(|ui| { - ui.checkbox(match_case, tl!("Match case")); - ui.checkbox(whole_word, tl!("Whole words")); - ui.checkbox(regex, tl!("Wildcards (regex)")); + let opts = json!({ + "text": query, + "with": replace, + "matchCase": *match_case, + "wholeWord": *whole_word, + "regex": *regex, + "wildcards": extra.wildcards, + "matchPrefix": extra.prefix, + "matchSuffix": extra.suffix, + "ignorePunct": extra.ignore_punct, + "ignoreSpace": extra.ignore_space, }); - let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex}); if !*replace_mode { ui.horizontal(|ui| { let lit = app.session.find.highlight; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e7f5b29..9488459 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -949,7 +949,6 @@ Wide Ancho Widow/Orphan Control Control de líneas viudas y huérfanas Widow/Orphan control Control de líneas viudas y huérfanas - Wildcards (regex) Comodines (expresión regular) Window Ventana Word Count Recuento de palabras Word Left Palabra a la izquierda @@ -1853,6 +1852,37 @@ Rewind Rebobinar Forward Avanzar Stroke {n} of {total} Trazo {n} de {total} + Use wildcards Usar caracteres comodín + Special Especial + Match prefix Coincidir prefijo + Match suffix Coincidir sufijo + Ignore punctuation characters Omitir signos de puntuación + Ignore white-space characters Omitir espacios en blanco + Regular expressions Expresiones regulares + Search Options Opciones de búsqueda + Tab Character Carácter de tabulación + Any Character Cualquier carácter + Any Digit Cualquier dígito + Any Letter Cualquier letra + Caret Character Acento circunflejo + Section Character Símbolo de sección + Paragraph Character Símbolo de párrafo + Graphic Gráfico + Manual Line Break Salto de línea manual + Manual Page Break Salto de página manual + Section Break Salto de sección + White Space Espacio en blanco + Clipboard Contents Contenido del portapapeles + Find What Text Texto buscado + Find What Expression Expresión buscada + Character in Range Carácter del intervalo + Beginning of Word Principio de palabra + End of Word Final de palabra + Expression Expresión + Not No + Num Occurrences Número de repeticiones + Previous 1 or More Anterior 1 o más veces + 0 or More Characters 0 o más caracteres Move with Text Mover con el texto Fix Position on Page Fijar posición en la página diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 453bac3..81428c6 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -833,7 +833,6 @@ Wide Lai Widow/Orphan Control Üksikridade vältimine Widow/Orphan control Üksikridade vältimine - Wildcards (regex) Metamärgid (regulaaravaldis) Window Aken Word Count Sõnade arv Word Left Sõna vasakule @@ -1816,6 +1815,37 @@ Rewind Keri algusesse Forward Edasi Stroke {n} of {total} Joon {n}/{total} + Use wildcards Kasuta metamärke + Special Erimärgid + Match prefix Sobita eesliide + Match suffix Sobita järelliide + Ignore punctuation characters Eira kirjavahemärke + Ignore white-space characters Eira tühikumärke + Regular expressions Regulaaravaldised + Search Options Otsingusuvandid + Tab Character Tabeldusmärk + Any Character Suvaline märk + Any Digit Suvaline number + Any Letter Suvaline täht + Caret Character Katusmärk + Section Character Paragrahvi märk + Paragraph Character Lõigu sümbol + Graphic Pilt + Manual Line Break Käsitsi reapiir + Manual Page Break Käsitsi lehepiir + Section Break Jaotisepiir + White Space Tühimärk + Clipboard Contents Lõikelaua sisu + Find What Text Otsitav tekst + Find What Expression Otsitav avaldis + Character in Range Märk vahemikus + Beginning of Word Sõna algus + End of Word Sõna lõpp + Expression Avaldis + Not Mitte + Num Occurrences Korduste arv + Previous 1 or More Eelmine 1 või rohkem korda + 0 or More Characters 0 või rohkem märki Move with Text Liigu tekstiga Fix Position on Page Fikseeri asukoht lehel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2798764..527b533 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -925,7 +925,6 @@ Wide 広い Widow/Orphan Control 改ページ時 1 行残して段落を区切らない Widow/Orphan control 改ページ時 1 行残して段落を区切らない - Wildcards (regex) ワイルドカード(正規表現) Window ウィンドウ Word Count 文字カウント Word document (*.docx) Word 文書 (*.docx) @@ -1837,6 +1836,37 @@ Rewind 巻き戻し Forward 早送り Stroke {n} of {total} ストローク {n}/{total} + Use wildcards ワイルドカードを使う + Special 特殊文字 + Match prefix 語頭が一致する + Match suffix 語尾が一致する + Ignore punctuation characters 句読点を無視する + Ignore white-space characters 空白文字を無視する + Regular expressions 正規表現 + Search Options 検索オプション + Tab Character タブ文字 + Any Character 任意の 1 文字 + Any Digit 任意の数字 + Any Letter 任意の英字 + Caret Character キャレット記号 + Section Character セクション記号 + Paragraph Character 段落記号文字 + Graphic 図 + Manual Line Break 手動の改行 + Manual Page Break 手動の改ページ + Section Break セクション区切り + White Space 空白 + Clipboard Contents クリップボードの内容 + Find What Text 検索した文字列 + Find What Expression 検索した式 + Character in Range 範囲内の文字 + Beginning of Word 単語の先頭 + End of Word 単語の末尾 + Expression 式 + Not 否定 + Num Occurrences 繰り返し回数 + Previous 1 or More 直前の 1 回以上の繰り返し + 0 or More Characters 0 文字以上の文字列 Move with Text 文字列と一緒に移動する Fix Position on Page ページ上で位置を固定する diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6e87f82..5401d6f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -925,7 +925,6 @@ Wide Ampla Widow/Orphan Control Controle de viúvas e órfãs Widow/Orphan control Controle de viúvas e órfãs - Wildcards (regex) Curingas (regex) Window Janela Word Count Contagem de palavras Word document (*.docx) Documento Word (*.docx) @@ -1849,6 +1848,37 @@ Rewind Retroceder Forward Avançar Stroke {n} of {total} Traço {n} de {total} + Use wildcards Usar caracteres curinga + Special Especial + Match prefix Coincidir prefixo + Match suffix Coincidir sufixo + Ignore punctuation characters Ignorar sinais de pontuação + Ignore white-space characters Ignorar espaços em branco + Regular expressions Expressões regulares + Search Options Opções de pesquisa + Tab Character Caractere de tabulação + Any Character Qualquer caractere + Any Digit Qualquer dígito + Any Letter Qualquer letra + Caret Character Acento circunflexo + Section Character Símbolo de seção + Paragraph Character Símbolo de parágrafo + Graphic Gráfico + Manual Line Break Quebra de linha manual + Manual Page Break Quebra de página manual + Section Break Quebra de seção + White Space Espaço em branco + Clipboard Contents Conteúdo da área de transferência + Find What Text Texto localizado + Find What Expression Expressão localizada + Character in Range Caractere no intervalo + Beginning of Word Início da palavra + End of Word Fim da palavra + Expression Expressão + Not Não + Num Occurrences Número de ocorrências + Previous 1 or More Anterior 1 ou mais vezes + 0 or More Characters 0 ou mais caracteres Move with Text Mover com o texto Fix Position on Page Fixar posição na página diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 95eb068..cc8a840 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -804,7 +804,6 @@ preview Title Пример Wide Широкие Widow/Orphan Control Запрет висячих строк Widow/Orphan control Запрет висячих строк - Wildcards (regex) Подстановочные знаки (regex) Window Окно Word Count Число слов Word document (*.docx) Документ Word (*.docx) @@ -2113,6 +2112,38 @@ preview Title Пример Apply Применить Convert Notes Преобразовать сноски +# Find and Replace special characters and wildcards (#543). + Use wildcards Подстановочные знаки + Special Специальный + Match prefix Начало слова + Match suffix Конец слова + Ignore punctuation characters Не учитывать знаки препинания + Ignore white-space characters Не учитывать пробелы + Regular expressions Регулярные выражения + Search Options Параметры поиска + Tab Character Знак табуляции + Any Character Любой знак + Any Digit Любая цифра + Any Letter Любая буква + Caret Character Знак вставки (^) + Section Character Знак раздела + Paragraph Character Знак абзаца + Graphic Графический объект + Manual Line Break Разрыв строки + Manual Page Break Разрыв страницы + Section Break Разрыв раздела + White Space Пробельный знак + Clipboard Contents Содержимое буфера обмена + Find What Text Текст для поиска + Find What Expression Найденное выражение + Character in Range Знак из диапазона + Beginning of Word Начало слова + End of Word Конец слова + Expression Выражение + Not Не + Num Occurrences Число вхождений + Previous 1 or More Предыдущий знак 1 или более раз + 0 or More Characters 0 или более знаков # Insert SmartArt (#497). Add Shape Добавить фигуру Change Colors Изменить цвета diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index f35c9b6..9eab4d3 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -985,7 +985,6 @@ Wide Široko Widow/Orphan Control Kontrola usamljenih redova Widow/Orphan control Kontrola usamljenih redova - Wildcards (regex) Džoker znaci (regex) Window Prozor Word Count Broj reči Word document (*.docx) Word dokument (*.docx) @@ -1847,6 +1846,37 @@ Rewind Na početak Forward Napred Stroke {n} of {total} Potez {n} od {total} + Use wildcards Koristi džoker znake + Special Posebno + Match prefix Podudari prefiks + Match suffix Podudari sufiks + Ignore punctuation characters Zanemari znake interpunkcije + Ignore white-space characters Zanemari beline + Regular expressions Regularni izrazi + Search Options Opcije pretrage + Tab Character Znak tabulatora + Any Character Bilo koji znak + Any Digit Bilo koja cifra + Any Letter Bilo koje slovo + Caret Character Znak kapice + Section Character Znak odeljka + Paragraph Character Simbol pasusa + Graphic Grafika + Manual Line Break Ručni prekid reda + Manual Page Break Ručni prekid strane + Section Break Prekid odeljka + White Space Belina + Clipboard Contents Sadržaj ostave + Find What Text Pronađeni tekst + Find What Expression Pronađeni izraz + Character in Range Znak u opsegu + Beginning of Word Početak reči + End of Word Kraj reči + Expression Izraz + Not Nije + Num Occurrences Broj ponavljanja + Previous 1 or More Prethodno 1 ili više puta + 0 or More Characters 0 ili više znakova Move with Text Pomeraj sa tekstom Fix Position on Page Fiksiraj položaj na stranici diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f9dc8bc..6db3d07 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -989,7 +989,6 @@ Wide Широко Widow/Orphan Control Контрола усамљених редова Widow/Orphan control Контрола усамљених редова - Wildcards (regex) Џокер знаци (regex) Window Прозор Word Count Број речи Word document (*.docx) Word документ (*.docx) @@ -1851,6 +1850,37 @@ Rewind На почетак Forward Напред Stroke {n} of {total} Потез {n} од {total} + Use wildcards Користи џокер знаке + Special Посебно + Match prefix Подудари префикс + Match suffix Подудари суфикс + Ignore punctuation characters Занемари знаке интерпункције + Ignore white-space characters Занемари белине + Regular expressions Регуларни изрази + Search Options Опције претраге + Tab Character Знак табулатора + Any Character Било који знак + Any Digit Било која цифра + Any Letter Било које слово + Caret Character Знак капице + Section Character Знак одељка + Paragraph Character Симбол пасуса + Graphic Графика + Manual Line Break Ручни прекид реда + Manual Page Break Ручни прекид стране + Section Break Прекид одељка + White Space Белина + Clipboard Contents Садржај оставе + Find What Text Пронађени текст + Find What Expression Пронађени израз + Character in Range Знак у опсегу + Beginning of Word Почетак речи + End of Word Крај речи + Expression Израз + Not Није + Num Occurrences Број понављања + Previous 1 or More Претходно 1 или више пута + 0 or More Characters 0 или више знакова Move with Text Померај са текстом Fix Position on Page Фиксирај положај на страници diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index da768e2..30f9743 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -798,7 +798,6 @@ Wide Geniş Widow/Orphan Control Tek Kalan Satır Denetimi Widow/Orphan control Tek kalan satır denetimi - Wildcards (regex) Joker karakterler (regex) Window Pencere Word Count Sözcük Sayısı Word Left Önceki Sözcük @@ -1735,6 +1734,38 @@ Apply Uygula Convert Notes Notları Dönüştür +# Find and Replace special characters and wildcards (#543). + Use wildcards Joker karakter kullan + Special Özel + Match prefix Ön eke uy + Match suffix Son eke uy + Ignore punctuation characters Noktalama karakterlerini yoksay + Ignore white-space characters Boşluk karakterlerini yoksay + Regular expressions Düzenli ifadeler + Search Options Arama Seçenekleri + Tab Character Sekme Karakteri + Any Character Herhangi Bir Karakter + Any Digit Herhangi Bir Rakam + Any Letter Herhangi Bir Harf + Caret Character Şapka Karakteri + Section Character Bölüm Karakteri + Paragraph Character Paragraf Karakteri + Graphic Grafik + Manual Line Break El ile Satır Sonu + Manual Page Break El ile Sayfa Sonu + Section Break Bölüm Sonu + White Space Boşluk + Clipboard Contents Pano İçeriği + Find What Text Aranan Metin + Find What Expression Aranan İfade + Character in Range Aralıktaki Karakter + Beginning of Word Sözcük Başı + End of Word Sözcük Sonu + Expression İfade + Not Değil + Num Occurrences Tekrar Sayısı + Previous 1 or More Öncekinin 1 veya Daha Fazlası + 0 or More Characters 0 veya Daha Fazla Karakter # Insert SmartArt (#497). Add Shape Şekil Ekle Change Colors Renkleri Değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ad1d8e9..e0494fb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -951,7 +951,6 @@ Wide Широкі Widow/Orphan Control Не залишати самотні рядки Widow/Orphan control Не залишати самотні рядки - Wildcards (regex) Шаблони (регулярні вирази) Window Вікно Word Count Кількість слів Word document (*.docx) Документ Word (*.docx) @@ -1849,6 +1848,37 @@ Rewind На початок Forward Вперед Stroke {n} of {total} Штрих {n} з {total} + Use wildcards Використовувати шаблони + Special Спеціальні + Match prefix Збіг префікса + Match suffix Збіг суфікса + Ignore punctuation characters Ігнорувати розділові знаки + Ignore white-space characters Ігнорувати пробільні символи + Regular expressions Регулярні вирази + Search Options Параметри пошуку + Tab Character Символ табуляції + Any Character Будь-який символ + Any Digit Будь-яка цифра + Any Letter Будь-яка літера + Caret Character Символ каретки + Section Character Знак параграфа + Paragraph Character Символ абзацу + Graphic Графіка + Manual Line Break Примусовий розрив рядка + Manual Page Break Примусовий розрив сторінки + Section Break Розрив розділу + White Space Пробіл + Clipboard Contents Вміст буфера обміну + Find What Text Знайдений текст + Find What Expression Знайдений вираз + Character in Range Символ із діапазону + Beginning of Word Початок слова + End of Word Кінець слова + Expression Вираз + Not Не + Num Occurrences Кількість повторень + Previous 1 or More Попередній 1 або більше разів + 0 or More Characters 0 або більше символів Move with Text Переміщувати з текстом Fix Position on Page Зафіксувати положення на сторінці diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bd465bd..0bce8ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -937,7 +937,6 @@ Wide 宽 Widow/Orphan Control 孤行控制 Widow/Orphan control 孤行控制 - Wildcards (regex) 通配符(正则表达式) Window 窗口 Word Count 字数统计 Word document (*.docx) Word 文档 (*.docx) @@ -1849,6 +1848,37 @@ Rewind 倒回 Forward 前进 Stroke {n} of {total} 第 {n} 笔,共 {total} 笔 + Use wildcards 使用通配符 + Special 特殊字符 + Match prefix 匹配前缀 + Match suffix 匹配后缀 + Ignore punctuation characters 忽略标点符号 + Ignore white-space characters 忽略空白字符 + Regular expressions 正则表达式 + Search Options 搜索选项 + Tab Character 制表符字符 + Any Character 任意字符 + Any Digit 任意数字 + Any Letter 任意字母 + Caret Character 脱字号 + Section Character 章节号 + Paragraph Character 段落符号 + Graphic 图形 + Manual Line Break 手动换行符 + Manual Page Break 手动分页符 + Section Break 分节符 + White Space 空白 + Clipboard Contents 剪贴板内容 + Find What Text 查找到的文本 + Find What Expression 查找到的表达式 + Character in Range 范围内的字符 + Beginning of Word 单词开头 + End of Word 单词结尾 + Expression 表达式 + Not 非 + Num Occurrences 出现次数 + Previous 1 or More 前一项 1 次或多次 + 0 or More Characters 0 个或多个字符 Move with Text 随文字移动 Fix Position on Page 在页面上的位置固定 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0b24232..37ba1e5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -926,7 +926,6 @@ Wide 寬 Widow/Orphan Control 遺留字串控制 Widow/Orphan control 遺留字串控制 - Wildcards (regex) 萬用字元(規則運算式) Window 視窗 Word Count 字數統計 Word document (*.docx) Word 文件 (*.docx) @@ -1838,6 +1837,37 @@ Rewind 倒轉 Forward 快轉 Stroke {n} of {total} 第 {n} 筆,共 {total} 筆 + Use wildcards 使用萬用字元 + Special 特殊字元 + Match prefix 比對字首 + Match suffix 比對字尾 + Ignore punctuation characters 忽略標點符號 + Ignore white-space characters 忽略空白字元 + Regular expressions 規則運算式 + Search Options 搜尋選項 + Tab Character 定位字元符號 + Any Character 任意字元 + Any Digit 任意數字 + Any Letter 任意字母 + Caret Character 插入號 + Section Character 章節符號 + Paragraph Character 段落符號 + Graphic 圖形 + Manual Line Break 手動換行 + Manual Page Break 手動分頁 + Section Break 分節符號 + White Space 空白 + Clipboard Contents 剪貼簿內容 + Find What Text 找到的文字 + Find What Expression 找到的運算式 + Character in Range 範圍內的字元 + Beginning of Word 單字開頭 + End of Word 單字結尾 + Expression 運算式 + Not 非 + Num Occurrences 出現次數 + Previous 1 or More 前一項 1 次以上 + 0 or More Characters 0 個以上字元 Move with Text 隨文字移動 Fix Position on Page 固定於頁面上的位置 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 80f06ce..20fac5f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1197,7 +1197,9 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.line(&[(5.0, 5.0), (15.0, 15.0)]); pen.line(&[(15.0, 5.0), (5.0, 15.0)]); } - "dropdown" => pen.arrow_down(10.0, 10.0, c), + // A triangle filling most of its box: it is drawn at 9–12 pt beside combos and split + // buttons, where a smaller one shrank to a dot (#535). + "dropdown" => pen.fill(&[(3.0, 7.0), (17.0, 7.0), (10.0, 14.0)], c), "more" => { for x in [5.0, 10.0, 15.0] { pen.fcircle(x, 10.0, 1.3, c); diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 14b8003..7765820 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -8,22 +8,38 @@ use serde_json::{Value, json}; use crate::theme::Tokens; use crate::{WordApp, widgets}; -/// Height of the bar (a little taller than the ribbon's 22 pt button row). -const BAR_H: f32 = 30.0; /// Vertical gap between the bar and the selection's first line. const VGAP: f32 = 8.0; /// Distance from the selection's left edge at which the bar is centred. const ANCHOR_INSET: f32 = 4.0; -/// Horizontal padding inside the bar. +/// Padding inside the bar. const PAD_X: f32 = 4.0; -/// Width of the font and size combos (the ribbon's metrics). -const COMBO_FONT_W: f32 = 150.0; -const COMBO_SIZE_W: f32 = 52.0; -/// Width of one icon button (the ribbon's small-button metric). +const PAD_Y: f32 = 3.0; +/// Height of one row of controls (the ribbon's 22 pt buttons plus a little air), and the gap +/// between the two rows. +const ROW_H: f32 = 24.0; +const ROW_GAP: f32 = 2.0; +/// Horizontal gap between neighbouring controls. +const SPACING: f32 = 1.0; +/// The ribbon's widget metrics: a small icon button, a split button (icon part plus its 11 pt +/// arrow, two allocations), a group divider and the size box. const BTN_W: f32 = 24.0; +const SPLIT_W: f32 = 24.0 + SPACING + 11.0; +const SEP_W: f32 = 9.0; +const COMBO_SIZE_W: f32 = 52.0; +/// Gap between the size box and the grow/shrink buttons. +const GAP: f32 = 3.0; +/// Row 2, like Word's: B I U S | highlight, font colour | the four alignments. Fourteen +/// allocations, so thirteen gaps; the two inside the split buttons are counted in `SPLIT_W`. +const ROW2_W: f32 = 8.0 * BTN_W + 2.0 * SPLIT_W + 2.0 * SEP_W + 11.0 * SPACING; +/// Row 1: font, size, grow, shrink. The font box takes what row 2 leaves, so both rows end +/// together and the box starts at the bar's left edge. +const COMBO_FONT_W: f32 = ROW2_W - (COMBO_SIZE_W + GAP + 2.0 * BTN_W + 3.0 * SPACING); -/// Fixed bar width, so it never jitters as the selection changes. -pub const BAR_W: f32 = PAD_X * 2.0 + COMBO_FONT_W + COMBO_SIZE_W + 3.0 + 8.0 * BTN_W + 3.0 * 9.0; +/// The bar's size: fixed, so it never jitters as the selection changes, and wide enough for every +/// control of both rows (#535: one row of a fixed width cut off everything after Align Left). +pub const BAR_W: f32 = PAD_X * 2.0 + ROW2_W; +pub const BAR_H: f32 = PAD_Y * 2.0 + ROW_H * 2.0 + ROW_GAP; /// Where the bar sits for a selection line and viewport; `None` for degenerate inputs (NaN /// rects must never reach egui). Pure geometry, unit tested without a window. @@ -40,6 +56,12 @@ pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> O Some(Rect::from_min_size(pos2(x, y), Vec2::new(BAR_W, BAR_H))) } +/// Where the bar and the controls in it were drawn last frame (`[bar, controls]`), kept in the +/// context's memory under this id so tests can check that no control spills out of the bar. +pub fn shown_id() -> egui::Id { + egui::Id::new("wc_mini_toolbar").with("shown") +} + /// 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. @@ -82,65 +104,80 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R painter.rect_filled(bar.translate(Vec2::new(0.0, 2.0)), CornerRadius::same(6), t.page_shadow); painter.rect_filled(bar, CornerRadius::same(6), t.menu); painter.rect_stroke(bar, CornerRadius::same(6), egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); - let inner = bar.shrink2(Vec2::new(PAD_X, 0.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::left_to_right(Align::Center))); - child.spacing_mut().item_spacing = Vec2::new(1.0, 0.0); + let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min))); + child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP); content(app, &mut child, &t); + let used = child.min_rect(); + ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used])); }); } -/// The controls, reusing the ribbon's widgets so the bar matches the ribbon exactly. +/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar +/// matches the ribbon exactly. fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default(); let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); let align = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props).align).unwrap_or_default(); + let row = |ui: &mut egui::Ui, app: &mut WordApp, add: &dyn Fn(&mut egui::Ui, &mut WordApp)| { + ui.allocate_ui_with_layout(Vec2::new(ui.available_width(), ROW_H), Layout::left_to_right(Align::Center), |ui| { + ui.set_min_height(ROW_H); + add(ui, app); + }); + }; - let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); - let font = app.previews.font_shown(&font); - let fams = app.previews.families(); - let prev = app.previews.font_preview_fn(); - if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { - let f = app.previews.font_stored(&f); - let _ = app.run("format.font", json!({"name": f})); - } - let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); - let sizes: Vec = - wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); - if let Some(v) = widgets::combo(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None) - && let Ok(x) = v.trim().parse::() - { - let _ = app.run("format.size", json!({"size": x})); - } - ui.add_space(3.0); - sep(ui, t); - - widgets::small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); - widgets::small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); - widgets::small(ui, app, "underline", None, "Underline", "format.underline", json!({}), flag("underline")); - widgets::small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); - sep(ui, t); - - let hl = app.canvas.last_highlight.clone(); - widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| { - highlight_menu(ui, app); + row(ui, app, &|ui, app| { + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let font = app.previews.font_shown(&font); + let fams = app.previews.families(); + let prev = app.previews.font_preview_fn(); + if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { + let f = app.previews.font_stored(&f); + let _ = app.run("format.font", json!({"name": f})); + } + let size = + st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); + let sizes: Vec = + wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); + if let Some(v) = widgets::combo(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None) + && let Ok(x) = v.trim().parse::() + { + let _ = app.run("format.size", json!({"size": x})); + } + ui.add_space(GAP); + widgets::small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false); + widgets::small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false); }); - let fc = app.canvas.last_font_color.clone(); - let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); - widgets::split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { - font_color_menu(ui, app); - }); - sep(ui, t); - use wordcraft_doc::Align as A; - widgets::small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); - widgets::small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); - widgets::small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); - widgets::small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); + row(ui, app, &|ui, app| { + widgets::small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); + widgets::small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); + widgets::small(ui, app, "underline", None, "Underline", "format.underline", json!({}), flag("underline")); + widgets::small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); + sep(ui, t); + + let hl = app.canvas.last_highlight.clone(); + widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| { + highlight_menu(ui, app); + }); + let fc = app.canvas.last_font_color.clone(); + let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); + widgets::split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { + font_color_menu(ui, app); + }); + sep(ui, t); + + use wordcraft_doc::Align as A; + widgets::small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); + widgets::small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); + widgets::small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); + widgets::small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); + }); } /// A hairline divider between the bar's groups. fn sep(ui: &mut egui::Ui, t: &Tokens) { - let (r, _) = ui.allocate_exact_size(Vec2::new(9.0, 20.0), Sense::hover()); + let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover()); let p = ui.painter(); p.vline(r.center().x, r.y_range().shrink(4.0), egui::Stroke::new(1.0, t.border)); } @@ -247,11 +284,35 @@ mod tests { assert!(r.min.x.is_finite() && r.min.y.is_finite()); } + /// #535: the bar was one fixed-width row that cut off everything after Align Left. Draw it + /// over a real selection: every control fits inside the bar, and the bar is no wider than its + /// controls (both rows end together). #[test] - fn bar_width_covers_exactly_its_controls() { - // The position maths uses BAR_W, so it must equal the width the widgets actually take: - // padding + two combos + a gap + eight 24 pt buttons + three 9 pt dividers. - let expected = PAD_X * 2.0 + COMBO_FONT_W + COMBO_SIZE_W + 3.0 + 8.0 * BTN_W + 3.0 * 9.0; - assert_eq!(BAR_W, expected); + fn every_control_fits_inside_the_bar() { + use wordcraft_engine::Session; + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "The quick brown fox jumps over the lazy dog"})).unwrap(); + a.run("select.text", json!({"text": "brown fox"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(1440.0, 900.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 [bar, used] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection"); + let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y)); + assert!( + inner.expand(0.1).contains_rect(used), + "controls {:?} spill out of the bar {:?}", + [used.min.x, used.min.y, used.max.x, used.max.y], + [inner.min.x, inner.min.y, inner.max.x, inner.max.y] + ); + assert!((used.width() - inner.width()).abs() < 0.5, "the bar ({}) should be as wide as its controls ({})", inner.width(), used.width()); + assert!(used.min.x - inner.min.x < 0.5, "the font box starts at the bar's left edge"); } } diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 608ae65..751d57a 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -89,7 +89,7 @@ impl Previews { return None; } let ppp = ui.ctx().pixels_per_point(); - let img = snippet( + let mut img = snippet( &Document::new(), Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }), 240.0, @@ -97,6 +97,7 @@ impl Previews { ppp, 2.0, )?; + ink_mask(&mut img); let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR); ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone())); Some(h) @@ -108,7 +109,7 @@ impl Previews { h.id(), Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - egui::Color32::WHITE, + t.text, ); } None => { @@ -208,6 +209,19 @@ fn math_image(nodes: &wordcraft_doc::math::Arg, display: bool, pt: f32, w: f32, Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px)) } +/// Turn a snippet rendered black on white paper into an ink mask: white whose alpha is the ink's +/// coverage, on a transparent background. Painted with the theme's text colour as the tint, a font +/// menu entry then reads like the rest of the menu in light and dark mode instead of sitting on a +/// white strip (#535). +pub fn ink_mask(img: &mut egui::ColorImage) { + for p in &mut img.pixels { + let [r, g, b, a] = p.to_srgba_unmultiplied(); + let lum = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000; + let ink = 255u32.saturating_sub(lum.min(255)) * u32::from(a) / 255; + *p = egui::Color32::from_white_alpha(u8::try_from(ink).unwrap_or(u8::MAX)); + } +} + /// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it. pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option { snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None) @@ -339,6 +353,17 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let galley = ui.painter().layout_job(job); let label_pos = pos2(r.center().x - galley.size().x / 2.0, r.max.y - 9.0 - galley.size().y / 2.0); ui.painter().with_clip_rect(r).galley(label_pos, galley, t.text); + // Right-click a tile: the same style actions as the Styles pane. + resp.context_menu(|ui| { + if ui.button(tl!("Update to Match Selection")).clicked() { + let _ = app.run("styles.updateToMatch", json!({"style": id})); + ui.close(); + } + if ui.button(tl!("Modify…")).clicked() { + app.dialog = crate::dialogs::Dialog::modify_style(app, id); + ui.close(); + } + }); if resp.on_hover_text(name).clicked() { let _ = app.run("para.style", json!({"style": id})); } @@ -468,4 +493,48 @@ mod tests { rx.recv_timeout(std::time::Duration::from_secs(30)).expect("font menu preview rendering hung or failed"); worker.join().unwrap(); } + + #[test] + fn ink_mask_keeps_the_ink_and_drops_the_paper() { + let mut img = egui::ColorImage::new([3, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::from_gray(128)]); + ink_mask(&mut img); + assert_eq!(img.pixels[0].a(), 0, "paper becomes transparent"); + assert_eq!(img.pixels[1], egui::Color32::from_white_alpha(255), "full ink stays opaque"); + assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage"); + } + + /// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the + /// theme's text colour and draw no background of their own. + #[test] + fn font_menu_entries_take_the_theme_text_colour() { + for theme in [egui::Theme::Dark, egui::Theme::Light] { + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, crate::theme::Appearance::System); + ctx.set_theme(theme); + let preview = Previews::default().font_preview_fn(); + let mut out = None; + for time in [1.0, 2.0] { + let input = egui::RawInput { time: Some(time), ..Default::default() }; + let mut o = ctx.run_ui(input, |ui| { + preview(ui, "Source Sans 3"); + }); + o.textures_delta.clear(); + out = Some(o); + } + let want = if theme == egui::Theme::Dark { Tokens::dark().text } else { Tokens::light().text }; + let shapes = out.map(|o| o.shapes).unwrap_or_default(); + let mut images = 0; + for s in &shapes { + match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => { + images += 1; + assert!(m.vertices.iter().all(|v| v.color == want), "{theme:?}: the entry is not tinted with the text colour"); + } + egui::Shape::Rect(r) => panic!("{theme:?}: an idle entry paints no background, got {:?}", r.fill), + _ => {} + } + } + assert_eq!(images, 1, "{theme:?}: the entry draws its rendered name"); + } + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d40d5e8..44c0584 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -559,8 +559,9 @@ fn combo_impl( } let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click()); ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent); - egui::Popup::menu(&aresp).show(|ui| { + icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent); + // The list drops from the box's left edge, as in Word, not from the arrow (#535). + egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| { if let Some(v) = combo_list(ui, width, current, items, preview) { out = Some(v); ui.close(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 537ca2f..99bb701 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every `w:numFmt` number format kept, East Asian ones drawn, #534; 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; @@ -46,7 +46,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | @@ -91,6 +91,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) | | 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) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index e7134ef..b5a15b4 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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 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, @@ -30,6 +30,7 @@ font can't match Word's metrics. | Drop caps | ✅ | 2026-10-06 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42) | 1–2 | +| List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | @@ -72,6 +73,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) | | 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) | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 7ca9359..2705b10 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **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 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 @@ -22,6 +22,7 @@ overall picture is in [`target-app-parity.md`](target-app-parity.md). | Shift+Enter in an item | line break inside the same item | `shift_enter_stays_inside_the_list_item` | | Paste: Keep Text Only `A`, empty line, `B` into an empty item | three paragraphs `A`, empty, `B`: pasted paragraph breaks are text, not Enter (they never end the list) | `plain_text_paste_keeps_an_empty_paragraph_in_a_list` | | `* ` `- ` `1. ` `1) ` `a. ` `a) ` `A. ` `i. ` `I. ` + text | bullet, dash bullet, 1. 1) a. a) A. i. I. lists | `list_autoformat_triggers` | +| `> ` `-> ` `=> ` + text | bulleted list with an arrow bullet (➢ ➔ ⇨; Word takes them from a symbol font, we use the Unicode arrows) ¹ | `arrow_bullets` | | `1. ` after other text | a new list restarting at 1 (never continues an earlier list) | `typing_1_dot_starts_numbering_again` | | ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` | @@ -70,7 +71,16 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin paragraphs Enter makes from it aren't links (#413; `a_link_covers_only_its_own_text`, `typing_at_either_end_of_a_link_is_not_linked`). - Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above - (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). + (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the + characters back and removes the border; the Enter stays (one undo step) ¹ + (`undo_right_after_a_border_line_brings_the_characters_back`). +- `+---+------+` Enter (plus signs around runs of hyphens, the whole paragraph) → a one-row + table across the text width, each column as wide as its share of the hyphens; the caret goes + into the first cell. ⌘Z right after brings the characters back. More than 63 columns stay + text ¹ (`plus_and_hyphen_line_becomes_a_table`). +- Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹ + (`smileys_are_replaced_inside_words_too`). + - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and @@ -78,6 +88,8 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin and "don't capitalize after" exceptions extend the abbreviation list. A replacement of several words takes a sentence capital on its first (`autocorrect_options_change_what_typing_does`). +¹ From Word's documented AutoFormat and AutoCorrect options; not yet keystroke-compared with +Word. ## Editing options (File › Options › Advanced) | Keys / setting | Result | Test | @@ -94,12 +106,10 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin - Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica Neue) is wider, so lines wrap earlier than in Word. Needs a metric-matched open substitute. -- `:)` inside a word (`ok:)`) isn't replaced (Word replaces it). - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows don't move yet (a selection across cells is refused). Go Back's places aren't mapped through later edits (they are clamped to the document). -- Not yet compared: `+---+---+` Enter (table AutoFormat), `> ` arrow bullets, undo after a - border line, Correct TWo INitial CApitals. +- Not yet compared: Correct TWo INitial CApitals; the rows marked ¹ above. ## How to compare @@ -115,6 +125,7 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c7fc518..83f89cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Outlining tab and outline keys, #493; 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 (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 @@ -12,7 +12,7 @@ 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) | ✅ | 80% | 1–2 | +| Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 | | 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 | @@ -37,6 +37,9 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Mini toolbar in two rows like Word's (font, size, grow, shrink; B I U S, highlight, font colour, four alignments) with every button inside the bar; font and size lists drop from the box's left edge; dropdown arrows a readable size; font list entries take the theme's text colour in dark mode (#535) | +| 2026-10-11 | trivial | Find and Replace: Special ▾ menu (^p, ^t, ^?, ^#, ^$, ^w, ^&, ^c … ; a shorter wildcard list), Use wildcards (`?` `*` `[a-z]` `[!x]` `{n,m}` `@` `<` `>` `( )`, `\1` in Replace with), Match prefix/suffix, Ignore punctuation and white space; `^p` matches and splits paragraphs, Replace All is one undo step (#543) | +| 2026-10-11 | trivial | Update Style to Match Selection takes how the selection looks (its paragraph and character styles as well as direct formatting) and applies the style to it; Home › Styles gallery tiles get a right-click menu with Update to Match Selection and Modify… (#555) | | 2026-10-11 | trivial | Outlining tab in Outline view (level box, promote/demote, move, expand/collapse, Show Level, Show Text Formatting, Show First Line Only, Close); outline symbols select a heading with its content on click and expand/collapse on double-click; Alt+Shift+Plus/Minus, Alt+Shift+1–9, Alt+Shift+A (#493) | | 2026-10-11 | trivial | File › Options as panes (General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents), every setting wired; `file.options` headless (#485) | | 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) |