Print: synchronize main updates for native CI

This commit is contained in:
skylar taylor-barrick 2026-10-12 03:51:41 +02:00
commit 8889e356d5
47 changed files with 2828 additions and 243 deletions

View File

@ -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-<ver>-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 |

View File

@ -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) |

View File

@ -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<Backends>) {
// 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<Dx12Adapter> {
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.

View File

@ -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();

View File

@ -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<char>,
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]);
}
}

View File

@ -12,6 +12,7 @@
pub mod bidi;
pub mod chart;
mod cjk_numbers;
pub mod connector;
pub mod control;
pub mod edit;

View File

@ -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();

View File

@ -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]

View File

@ -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) {

View File

@ -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:

View File

@ -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<InlineObject>),
}

View File

@ -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#"<w:lvl w:ilvl="{i}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:suff w:val="nothing"/><w:lvlText w:val="{text}"/></w:lvl>"#)
};
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#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0">{levels}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let item = |lv: u8| format!(r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="{lv}"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>x</w:t></w:r></w:p>"#);
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::<Vec<_>>();
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<String> =
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]

View File

@ -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<CommandSpec> {
.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<String>,
}
/// 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<Vec<(Pos, Pos)>, 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<Vec<Found>, 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<std::ops::Range<usize>> = 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<Seg> = Vec::new();
// Where the live text breaks (tracked-deleted text, or a paragraph edge when not spanning).
let mut cuts: Vec<usize> = 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<Pos> {
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() {

View File

@ -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<char> {
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<char> {
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<Compiled, String> {
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<char> = query.chars().collect();
let mut atoms: Vec<String> = 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<char> = 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<String, String> {
let bad = || format!("{{{body}}} is not a valid count");
let num = |s: &str| -> Result<Option<u32>, 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<String, String> {
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<Piece> {
if o.literal || o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
let mut out = Vec::new();
let mut cur = String::new();
let flush = |cur: &mut String, out: &mut Vec<Piece>| {
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<String>, 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
}

View File

@ -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;

View File

@ -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<wordcraft_doc::CharProps> =
v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
let chr: Option<CharProps> = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
let para: Option<ParaProps> =
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(&para_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(&para_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: serde::Serialize>(t: &T) -> serde_json::Map<String, Value> {
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<T: serde::Serialize + serde::de::DeserializeOwned>(own: &T, have: &T, want: &T, keep: &[&str]) -> Result<T, CmdError> {
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<T: serde::Serialize + serde::de::DeserializeOwned + Clone>(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.

View File

@ -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<bool, CmdError> {
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<bool, CmdError> {
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<bool, CmdError> {
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<usize>),
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
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<bool, CmdError> {
|| !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<bool, CmdError> {
'*' => (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<Vec<usize>> {
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<bool, CmdError> {
};
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<bool, CmdError> {
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::<usize>().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).

View File

@ -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;

View File

@ -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)>,

View File

@ -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": "<cat", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat>", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "<c*t>", "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();

View File

@ -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.

View File

@ -292,6 +292,13 @@ pub struct FontDb {
/// System fallback state: enabled, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
/// 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<CoverageIndex>,
/// 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<char>,
/// 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<u32>,
/// The cataloged font files, sorted.
files: Vec<IndexedFile>,
/// 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<Box<[(u32, u32)]>>,
/// 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<Vec<u8>> {
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<std::path::PathBuf> = 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::path::PathBuf, IndexedFile> =
std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect();
let mut paths: Vec<std::path::PathBuf> = 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
}

View File

@ -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<u32> = 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<u8> {

View File

@ -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

View File

@ -69,6 +69,10 @@ pub fn format_switch(instr: &str) -> Option<NumFormat> {
"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 }");

View File

@ -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<Glyph>,
/// 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<LabelRun>,
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<usize>,
/// 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<LabelRun> = 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.

View File

@ -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<u32> = 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::<Vec<_>>(), ["一、", "二、", "三、"]);
// 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::<String>(), 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.

View File

@ -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;

View File

@ -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

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

View File

@ -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

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

View File

@ -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 ページ上で位置を固定する

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

View File

@ -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

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

View File

@ -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 Изменить цвета

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

View File

@ -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

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

View File

@ -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 Фиксирај положај на страници

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

View File

@ -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

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

View File

@ -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 Зафіксувати положення на сторінці

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

View File

@ -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 在页面上的位置固定

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

View File

@ -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 固定於頁面上的位置

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

View File

@ -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);

View File

@ -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<String> =
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::<f64>()
{
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<String> =
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::<f64>()
{
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");
}
}

View File

@ -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<egui::ColorImage> {
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");
}
}
}

View File

@ -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();

View File

@ -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) |

View File

@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (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) |

View File

@ -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) |

View File

@ -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) |