Numbering: Chinese, Japanese and Korean number formats (一、二、三 …) (#534) (#561)

* Numbering: Chinese, Japanese and Korean number formats (一、二、三 …) (#534)

A list with w:numFmt="chineseCounting" and lvlText "%1、" showed "2、", and the
digit was drawn with glyph ids of the CJK font that supplied the comma, so it
came out as an unrelated letter: the list label was laid out in font runs but
drawn with the style of its last run only.

- NumFormat knows every ST_NumberFormat value (ECMA-376 §17.18.59), so a file
  saves back the format it was opened with; unknown values are decimal.
- New doc::cjk_numbers: Chinese/Taiwanese counting (with 十 below 100, digit by
  digit above) and counting-thousand (百/千/万/亿, 零 for gaps), simplified and
  traditional legal numerals, Japanese counting/legal/digital ten-thousand,
  Korean counting/digital/legal, ideograph digits, heavenly stems, earthly
  branches and the sixty-year cycle, aiueo/iroha (half and full width),
  ganada/chosung, enclosed numbers (①, ⒈, ⑴, ㊀) and full-width digits. Values
  past a sequence's end fall back to decimal.
- Legal numbering (w:isLgl) shows every level, the current one too, in decimal.
- List labels keep their font runs and draw each with its own face.
- A list level without w:ind no longer gets a 36pt indent on read.
- .doc number format codes 0x00-0x27 map to the formats (0x16 decimalZero).
- PAGE/NUMPAGES fields take \* ChineseNum1/2/3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rustfmt after merging main

* Layout test: end the list range inside the third paragraph (#477 skips a paragraph the range ends at the start of)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 03:35:41 +02:00 committed by GitHub
parent b2c51c6500
commit 60cd1bf568
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 834 additions and 54 deletions

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

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

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

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