Merge upstream/main into zoom-stretch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-08 02:26:30 +01:00
co-authored by Claude Opus 5.5
52 changed files with 1624 additions and 127 deletions
+8
View File
@@ -18,7 +18,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
@@ -31,6 +38,7 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+15 -1
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -478,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
/// notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -490,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
match serde_json::to_vec(&(
sections,
parts,
s.even_odd_headers,
&s.page_color,
&s.page_picture,
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+1
View File
@@ -125,6 +125,7 @@ mod tests {
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
+21 -4
View File
@@ -289,13 +289,14 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::ThaiNumbers => thai_digits(n),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
@@ -349,7 +350,6 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -357,6 +357,11 @@ impl NumFormat {
}
}
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
fn thai_digits(n: u32) -> String {
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
}
/// 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}'];
@@ -999,6 +1004,18 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
#[test]
fn thai_numbers_use_thai_digits() {
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
// Formats not drawn yet still show decimal.
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
+52 -13
View File
@@ -7,9 +7,26 @@ use crate::DocbinError;
/// Largest bin table (entries) we accept.
const MAX_BINS: usize = 100_000;
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
#[derive(Clone, Copy)]
pub(crate) struct ChpxRun<'w> {
pub(crate) grpprl: &'w [u8],
lo: u32,
hi: u32,
}
impl ChpxRun<'_> {
pub(crate) fn contains(&self, fc: u32) -> bool {
self.lo <= fc && fc < self.hi
}
}
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
pub(crate) struct Bins {
fcs: Vec<u32>,
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
/// table that does not gets no cached ranges, [`ChpxRun`]).
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
@@ -20,7 +37,7 @@ impl Bins {
let fc = fc as usize;
let lcb = lcb as usize;
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
}
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
@@ -38,36 +55,58 @@ impl Bins {
// PnBte* keeps the page number in its low bits; the rest must be zero.
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
}
Ok(Bins { fcs, pns })
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
Ok(Bins { fcs, sorted, pns })
}
/// The page containing `fc`, as a slice of the WordDocument stream.
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
/// is not ascending, so nothing is cached from it).
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
let pn = *self.pns.get(i)?;
let at = pn.checked_mul(512)? as usize;
word.get(at..at.checked_add(512)?)
let page = word.get(at..at.checked_add(512)?)?;
if !self.sorted {
return Some((page, 1, 0));
}
let lo = self.fcs.get(i).copied().unwrap_or(0);
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
Some((page, lo, hi))
}
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
/// crun in the last byte).
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
let Some(page) = self.page(word, fc) else { return &[] };
let Some(&crun) = page.get(511) else { return &[] };
self.chpx_run(word, fc).grpprl
}
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
/// over consecutive characters looks it up once per run, not once per character.
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
let Some(&crun) = page.get(511) else { return none };
let crun = crun.min(0x65) as usize;
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
// region between rgb's end and crun.
let rgb_at = (crun + 1) * 4;
// The last run starting at or before `fc`. It stays the answer for every FC from its own
// start up to the smallest start after it (also in a page whose starts do not ascend).
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
let Some(j) = j else { return &[] };
let Some(&off) = page.get(rgb_at + j) else { return &[] };
let first_after = j.map_or(0, |j| j + 1);
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
let Some(j) = j else { return run(&[]) };
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
if off == 0 {
return &[];
return run(&[]);
}
let at = off as usize * 2;
let Some(&cb) = page.get(at) else { return &[] };
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
let Some(&cb) = page.get(at) else { return run(&[]) };
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
}
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
@@ -75,7 +114,7 @@ impl Bins {
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
let Some(page) = self.page(word, fc) else { return (0, &[]) };
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
let Some(&crun) = page.get(511) else { return (0, &[]) };
// BX is 13 bytes; cap crun at what fits before byte 511.
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
+81 -42
View File
@@ -502,29 +502,37 @@ struct ParaBuild {
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
run_gen: Option<u64>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
impl ParaBuild {
fn push(&mut self, c: char, props: CharProps) {
self.run_gen = None;
let len = c.len_utf8();
match &self.cur {
Some(p) if *p == props => {
if let Some(r) = self.runs.last_mut() {
r.len += len;
}
}
_ => {
self.runs.push(Run { len, props: props.clone() });
self.cur = Some(props);
}
match self.runs.last_mut() {
Some(r) if r.props == props => r.len += len,
_ => self.runs.push(Run { len, props }),
}
self.text.push(c);
}
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
/// they are cloned only when a run starts, and not even compared while `generation` stays
/// the same.
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
_ => self.runs.push(Run { len, props: props.clone() }),
}
self.run_gen = Some(generation);
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
@@ -552,7 +560,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut memo: Option<FmtMemo<'a>> = None;
let mut fmt = FmtState::default();
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -715,16 +723,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
}
cp = cp.saturating_add(units);
}
@@ -854,34 +862,53 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
// The piece and the CHPX run of the previous character usually cover this one too.
let span = match st.span.filter(|s| s.contains(cp)) {
Some(s) => Some(s),
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
st.span = ctx.pieces.span(cp);
st.span
}
};
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
let run = match st.chpx.filter(|r| r.contains(fc)) {
Some(r) => r,
None => {
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
st.chpx = Some(r);
r
}
};
let grpprl = run.grpprl;
let prm = span.map_or(0, |s| s.prm());
let style = pb.props.style.as_deref();
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
if !hit {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(sty) => &sty.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
st.generation += 1;
}
let Some(m) = st.memo.as_ref() else { return };
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
if field.is_none() && symbol.is_none() {
pb.push_shared(c, &m.props, st.generation);
return;
}
let mut props = m.props.clone();
let mut c = c;
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some((ftc, xchar)) = symbol {
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
@@ -900,6 +927,18 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
}
}
/// What the walk carries from one character to the next while it resolves formatting: the piece
/// and the CHPX run the last character was in (so most characters need no search), and the last
/// formatting resolved.
#[derive(Default)]
struct FmtState<'a> {
span: Option<piece::PieceSpan<'a>>,
chpx: Option<fkp::ChpxRun<'a>>,
memo: Option<FmtMemo<'a>>,
/// Counts the memo's replacements, so equal values mean the same properties.
generation: u64,
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
+36 -6
View File
@@ -117,12 +117,6 @@ impl PieceTable {
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
@@ -143,6 +137,17 @@ impl PieceTable {
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
}
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
/// so a sequential walk searches the piece table once per piece.
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
let pcd = self.pieces.get(i)?;
let cp_lo = *self.cps.get(i)?;
let cp_hi = *self.cps.get(i + 1)?;
Some(PieceSpan { cp_lo, cp_hi, pcd })
}
/// The CP just past the last piece: no text exists at or beyond it.
pub(crate) fn cp_end(&self) -> u32 {
self.cps.last().copied().unwrap_or(0)
@@ -187,6 +192,31 @@ impl PieceTable {
}
}
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
#[derive(Clone, Copy)]
pub(crate) struct PieceSpan<'a> {
cp_lo: u32,
cp_hi: u32,
pcd: &'a Pcd,
}
impl PieceSpan<'_> {
pub(crate) fn contains(&self, cp: u32) -> bool {
self.cp_lo <= cp && cp < self.cp_hi
}
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
}
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
pub(crate) fn prm(&self) -> u16 {
self.pcd.prm
}
}
/// What a piece's `Prm` refers to.
pub(crate) enum PrmRef<'a> {
None,
+150
View File
@@ -2880,3 +2880,153 @@ fn field_results_keep_their_text_and_runs() {
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}
+1
View File
@@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() {
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
+6
View File
@@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
@@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
@@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
@@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
+38
View File
@@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else if let Some(text) = retyped_text(s, v) {
// Text to display changed: the selection is retyped with it, then linked (as in Word).
crate::cmd::paste_text(s, &text)?;
let end = s.sel.focus.clone();
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
s.doc.format_range(&start, &end, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
@@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The `text` to retype a selection with before linking it: only when it differs from the selected
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
/// What can't stand in one line of text: breaks, and pictures or fields.
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
if text.contains(NOT_ONE_LINE) {
return None;
}
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return None;
}
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
if shown.contains(NOT_ONE_LINE) || shown == text {
return None;
}
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
// the next update of the field, not here.
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
return None;
}
Some(text.to_string())
}
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool {
+30
View File
@@ -452,6 +452,36 @@ mod tests {
assert_eq!(numbered(&mut b), want);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
s.run("select.all", &json!({})).unwrap();
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #1793: the other heading presets of Word's List Library, level by level.
#[test]
fn every_heading_preset_numbers_three_heading_levels() {
+4 -3
View File
@@ -103,8 +103,9 @@ pub fn specs() -> Vec<CommandSpec> {
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
/// argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
@@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
'\\' if chars.peek().is_some() => w.extend(chars.next()),
c => w.push(c),
}
}
+14 -1
View File
@@ -1,3 +1,4 @@
use super::properties::{new_property_value, text_result};
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
@@ -446,7 +447,7 @@ fn update_fields_apply_the_capitalization_switches() {
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
"new * Upper"
]
);
}
@@ -548,6 +549,18 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
#[test]
fn quoted_field_argument_backslash_escapes() {
for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]
{
let value = new_property_value(instr).unwrap();
assert_eq!(value, shown, "{instr}");
assert_eq!(text_result(instr, value), shown, "{instr}");
}
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
+139 -8
View File
@@ -4681,10 +4681,8 @@ fn typed_paragraphs_are_capped_for_every_caller() {
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = [
fn hostile_junk() -> [serde_json::Value; 8] {
[
json!(null),
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
@@ -4693,11 +4691,23 @@ fn hostile_params_never_panic() {
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
];
]
}
/// `file.*`, `insert.picture` and `insert.textFromFile`: `hostile_file_params_never_panic` runs
/// them, held to a folder of its own, and `hostile_params_never_panic` every other command.
fn reaches_files(id: &str) -> bool {
id.starts_with("file.") || id == "insert.picture" || id == "insert.textFromFile"
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = hostile_junk();
for spec in reg.all() {
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
// is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below.
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
// Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its
// backend is the silent `Hold`, asserted below.
if reaches_files(spec.id) {
continue;
}
for j in &junk {
@@ -4711,6 +4721,127 @@ fn hostile_params_never_panic() {
}
}
/// `file.*`, `insert.picture` and `insert.textFromFile` take junk params and paths to files they
/// can't use (#3719). They may read only `in` and write only `out`, so a save can't replace a file
/// another run is meant to find broken, and nothing outside those folders is touched.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn hostile_file_params_never_panic() {
/// What is directly in `dir`: each name with its bytes (`None` for a folder).
fn contents(dir: &std::path::Path) -> std::collections::BTreeMap<String, Option<Vec<u8>>> {
let entries = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path());
entries.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).ok())).collect()
}
/// The names whose bytes differ, so a failure lists them instead of printing every file.
fn changed(before: &std::collections::BTreeMap<String, Option<Vec<u8>>>, dir: &std::path::Path) -> Vec<String> {
let after = contents(dir);
let names: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect();
names.into_iter().filter(|n| before.get(*n) != after.get(*n)).cloned().collect()
}
let reg = cmd::registry();
let base = scratch_dir("hostile-files");
let (input, output, outside) = (base.join("in"), base.join("out"), base.join("outside"));
for dir in [&outside, &input.join("folder.docx"), &output.join("folder.docx")] {
std::fs::create_dir_all(dir).unwrap();
}
let at = |dir: &std::path::Path, name: &str| dir.join(name).to_string_lossy().into_owned();
// Fixed bytes, so a failure comes back on the next run.
let noise: Vec<u8> = (0..512u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
let picture_exts = ["png", "jpg", "gif", "bmp", "svg", "emf"];
let mut paths = vec![];
for ext in crate::io::OPEN_EXTS.iter().chain(&picture_exts) {
let name = format!("random.{ext}");
std::fs::write(input.join(&name), &noise).unwrap();
paths.push(at(&input, &name));
}
paths.extend(crate::io::SAVE_EXTS.iter().map(|ext| at(&output, &format!("new.{ext}"))));
std::fs::write(input.join("empty"), b"").unwrap();
std::fs::write(input.join("empty.docx"), b"").unwrap();
std::fs::write(input.join("good.txt"), "Good text").unwrap();
std::fs::write(input.join("good.png"), tiny_png()).unwrap();
paths.extend(["empty", "empty.docx", "folder.docx", "missing", "missing.docx"].map(|name| at(&input, name)));
paths.extend(["folder.docx", "missing/new.docx", &format!("{}.docx", "x".repeat(5000))].map(|name| at(&output, name)));
// Outside the roots, however it is spelled, and not paths at all.
paths.extend(["new.docx", "new"].map(|name| at(&outside, name)));
paths.push(format!("{}/../outside/new.docx", input.to_string_lossy()));
paths.extend(["hostile-relative.docx", "hostile-relative", "", "\0", "a\0b.docx"].map(String::from));
// Every path alone; each of these also with every other kind of junk param. The numbers that
// cost time (`copies`, `scale`) stay small: huge ones are #3720.
let mut params: Vec<serde_json::Value> = hostile_junk().into();
params.extend(paths.iter().map(|p| json!({"path": p})));
for name in [
"in/random.docx",
"in/empty.docx",
"in/folder.docx",
"in/missing.docx",
"in/good.txt",
"in/good.png",
"out/new.docx",
"out/new.pdf",
"out/new.png",
] {
let path = at(&base, name);
params.extend([
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
json!({"path": path, "password": 5, "format": 5, "markup": true}),
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
json!({"path": path, "data": "not base64!"}),
json!({"path": path, "data": "AAAA"}),
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
json!({"dir": path, "name": path, "template": path, "userTemplatesFolder": path, "id": 1e30, "restore": 1e30, "save": path}),
]);
}
let before = contents(&input);
let working_dir = std::path::Path::new(".");
let working_dir_before = contents(working_dir);
let roots = crate::file_access::AutomationRoots::new(Some(&input), Some(&output)).unwrap();
let confined = |s: &mut Session, id: &str, v: &serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(id, v));
for spec in reg.all() {
if !reaches_files(spec.id) {
continue;
}
for v in &params {
let mut s = Session::new(crate::sample::sample_document());
let r = confined(&mut s, spec.id, v);
assert!(!caught_panic(&reg, &r), "{} panicked on {v}: {r:?}", spec.id);
s.clamp_selection();
let _ = s.layout();
}
}
let nothing = Vec::<String>::new();
assert_eq!(changed(&before, &input), nothing, "the read root is only read");
assert_eq!(changed(&Default::default(), &outside), nothing, "nothing outside the write root");
// Every name counts: an empty path comes out as `.pdf` or `.dotx` here.
assert_eq!(changed(&working_dir_before, working_dir), nothing, "nothing in the working directory");
// The junk above met the files, not a closed door: usable files work through the same roots...
let mut s = s();
confined(&mut s, "file.open", &json!({"path": at(&input, "good.txt")})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Good text");
confined(&mut s, "insert.textFromFile", &json!({"path": at(&input, "good.txt")})).unwrap();
confined(&mut s, "insert.picture", &json!({"path": at(&input, "good.png")})).unwrap();
let saved: [(&str, &str, &[u8]); 3] =
[("file.saveAs", "control.docx", b"PK\x03\x04"), ("file.exportPdf", "control.pdf", b"%PDF-"), ("file.exportPng", "control.png", b"\x89PNG")];
for (id, name, signature) in saved {
confined(&mut s, id, &json!({"path": at(&output, name)})).unwrap();
assert_eq!(std::fs::read(output.join(name)).unwrap().get(..signature.len()), Some(signature), "{name}");
}
// ...the noise is refused by the reader, and only a path outside by the roots.
let refused = |s: &mut Session, path: String| match confined(s, "file.open", &json!({"path": path})) {
Err(crate::CmdError::Failed(m)) => m,
r => panic!("not refused: {r:?}"),
};
let unreadable = refused(&mut s, at(&input, "random.docx"));
assert!(!unreadable.contains("automation path rejected"), "{unreadable}");
let rejected = refused(&mut s, at(&outside, "new.docx"));
assert!(rejected.contains("automation path rejected"), "{rejected}");
let _ = std::fs::remove_dir_all(&base);
}
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
/// those words.
+104
View File
@@ -1539,6 +1539,110 @@ fn typing_at_either_end_of_a_link_is_not_linked() {
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
}
#[test]
fn a_link_on_a_selection_shows_the_text_to_display() {
// Ctrl+K on selected text with a new Text to display: the selection is retyped with it and
// linked, keeping its formatting; typing after it stays outside the link (as in Word).
let mut s = typed("go see kiln now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
typ(&mut s, "!");
assert_eq!(texts(&s), ["go see oven! now"]);
assert_eq!(links(&s), [("Oven".into(), false)]);
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting");
// The same text, or text that can't stand in one line, leaves the selection as it is.
run(&mut s, "select.text", json!({"text": "see"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"}));
run(&mut s, "select.text", json!({"text": "now"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"}));
assert_eq!(texts(&s), ["go see oven! now"]);
}
#[test]
fn a_new_selection_drops_the_formatting_left_after_a_link() {
// A link leaves plain formatting for what is typed after it. A selection made next doesn't
// take it: retyping that selection keeps its own formatting.
let bold_run = |s: &Session, text: &str| {
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true))
};
let mut s = typed("kiln");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"}));
assert!(bold_run(&s, "Stove"), "select.all");
run(&mut s, "select.word", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"}));
assert!(bold_run(&s, "Range"), "select.word");
let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap();
let focus = serde_json::to_value(s.sel.focus.clone()).unwrap();
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"}));
assert!(bold_run(&s, "Hob"), "select.range");
}
#[test]
fn a_link_over_two_paragraphs_keeps_their_text() {
// Word shows <<Selection in Document>> for such a selection: the text to display is ignored.
let mut s = typed("one\ntwo");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"}));
assert_eq!(texts(&s), ["one", "two"]);
assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]);
}
#[test]
fn a_retyped_link_text_is_tracked() {
let mut s = typed("go see kiln now");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion");
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some()));
}
#[test]
fn a_link_keeps_a_selection_it_cannot_retype() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{InlineObject, Path};
let url = "https://example.org/";
// A second retyped link keeps its own selection's formatting: the first one's left no
// formatting pending for it.
let mut s = typed("go see kiln and vat now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Oven"}));
run(&mut s, "select.text", json!({"text": "vat"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "Tub"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text);
// A selection holding a manual line break keeps both lines.
let mut s = typed("one");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "two");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "both"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo");
// A selection in a field's result is only linked; the field's next update owns that text.
let mut doc = wordcraft_doc::Document::from_text("See ");
let c = CharProps::default();
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
let start = p.len();
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
let mut s = Session::new(doc);
run(&mut s, "select.text", json!({"text": "Smith"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Jones"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)");
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1);
}
#[test]
fn typing_then_selecting_then_typing_are_two_undo_steps() {
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
+1
View File
@@ -1122,6 +1122,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa
width: t.props.width,
width_pct: t.props.width_pct,
heights,
keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(),
fixed: t.props.fixed,
space: [t.props.space_before, t.props.space_after],
header_rows: t.rows.iter().take_while(|r| r.props.header).count(),
+1
View File
@@ -844,6 +844,7 @@ impl<'a> Builder<'a> {
row.props.height = Some(h.min(1584.0));
row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast };
}
row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true));
let mut g = 0usize;
for c in r.iter().take(wordcraft_doc::table::MAX_COLS) {
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
+2
View File
@@ -943,6 +943,8 @@ pub struct TableExtra {
pub width_pct: Option<f32>,
/// Each row's height (points) and whether it is exact (else at least), by row.
pub heights: Vec<Option<(f32, bool)>>,
/// Whether each row keeps from breaking across pages, by row.
pub keep_rows: Vec<bool>,
/// Its columns keep their widths, never fitted to their content (Word's fixed layout,
/// `w:tblLayout`): an ODT table's always do.
pub fixed: bool,
+5 -1
View File
@@ -1391,19 +1391,23 @@ impl Body<'_> {
// LibreOffice's tables keep their columns' widths, whatever their content (and however
// far past the margins): Word's fixed layout.
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new(), fixed: true, space, header_rows: 0 };
let extra =
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
let keep = self.styles.row_keep(get(a, "table:style-name"));
if let Some(t) = self.tables.last_mut() {
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
t.extra.heights.truncate(t.rows.len());
t.extra.heights.push(height);
t.extra.keep_rows.truncate(t.rows.len());
t.extra.keep_rows.push(keep);
}
}
}
+5 -1
View File
@@ -87,7 +87,11 @@ impl TableB {
let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some());
let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible()));
let extra = self.extra;
let kept = extra.fixed || extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some);
let kept = extra.fixed
|| extra.align.is_some()
|| extra.width_pct.is_some()
|| extra.heights.iter().any(Option::is_some)
|| extra.keep_rows.contains(&true);
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None }
}
}
+19 -1
View File
@@ -252,6 +252,8 @@ struct StyleInfo {
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
row_keep: Option<bool>,
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
/// page of that master page. Empty: explicitly none, which stops the lookup up the chain.
master_page: Option<String>,
@@ -623,6 +625,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), false));
}
if let Some(k) = get(a, "fo:keep-together") {
s.row_keep = Some(k.trim() == "always");
}
}
"table-column-properties" => {
if let Some(w) = get(a, "style:column-width").and_then(length) {
@@ -1043,6 +1048,8 @@ pub(super) struct Styles {
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
default_row_keep: Option<bool>,
/// The named dashes and gradients graphic styles use.
pub(super) defs: super::strokes::Defs,
/// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
@@ -1055,6 +1062,7 @@ impl Styles {
let mut cur: Option<(String, StyleInfo)> = None;
let mut in_default = false;
let mut in_graphic_default = false;
let mut in_row_default = false;
let mut dates = DateStyles::default();
let mut list: Option<(String, Vec<ListLevel>)> = None;
// The list or outline level being read.
@@ -1083,8 +1091,12 @@ impl Styles {
"default-style" if q.starts_with("style:") => {
in_default = get(&a, "style:family") == Some("paragraph");
in_graphic_default = get(&a, "style:family") == Some("graphic");
in_row_default = get(&a, "style:family") == Some("table-row");
}
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
"table-row-properties" if in_row_default && cur.is_none() => {
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
}
"text-properties" if in_default && cur.is_none() => {
self.default_font = default_font(&a, &self.faces);
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
@@ -1151,7 +1163,7 @@ impl Styles {
},
Ev::End(q) => match local(&q) {
"automatic-styles" => in_auto = false,
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false),
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
"style" if q.starts_with("style:") => {
if let Some((n, s)) = cur.take()
&& self.map.len() < 100_000
@@ -1355,6 +1367,12 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.row_height)
}
/// Whether a table row doesn't break across pages: its style says (whole chain), else the
/// default style.
pub(super) fn row_keep(&self, name: Option<&str>) -> bool {
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.row_keep)).or(self.default_row_keep).unwrap_or(false)
}
/// Background colour of a table-cell style (whole chain).
/// A table-cell style's background picture: its package path, repeat and position.
pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> {
@@ -159,3 +159,75 @@ fn a_table_starting_a_page_starts_it() {
let c = content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
}
#[test]
fn a_row_that_keeps_together_says_so() {
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
let styles = r#"<style:style style:name="R" style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:style><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in" fo:keep-together="always"/></style:style>"#;
let row = |s: &str, t: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>{t}</text:p></table:table-cell></table:table-row>"#);
let table = format!(
"<table:table>{}{}{}</table:table>",
row(r#" table:style-name="R""#, "kept"),
row("", "free"),
row(r#" table:style-name="H""#, "kept and tall")
);
let shape = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().map(|r| (r.props.cant_split, r.props.height)).collect::<Vec<_>>())
.unwrap_or_default()
};
let d = doc(styles, &table);
assert_eq!(shape(&d), [(true, None), (false, None), (true, Some(72.0))]);
let saved = crate::odt::tests::export_model(&d).unwrap();
assert!(content_of(&saved).contains(r#"fo:keep-together="always""#));
assert_eq!(shape(&import(&saved).unwrap()), [(true, None), (false, None), (true, Some(72.0))]);
}
/// A row whose style says nothing of it, or a row with no style, follows the default style; one
/// that says otherwise doesn't.
#[test]
fn a_rows_keep_together_defaults_to_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in"/></style:style><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table>{}{}{}</table:table></office:text></office:body></office:document-content>"#,
row(""),
row(r#" table:style-name="H""#),
row(r#" table:style-name="F""#)
);
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
let d = import(&package(&content, Some(styles))).unwrap();
assert_eq!(keeps(&d), [true, true, false]);
assert_eq!(keeps(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), [true, true, false]);
}
/// A row free to break under a default row style that keeps rows together says so when written from
/// the model, or a save takes that away: the file's default style stays.
#[test]
fn a_row_free_to_break_stays_so_over_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column/>{}{}</table:table></office:text></office:body></office:document-content>"#,
row(r#" table:style-name="F""#),
row("")
);
let mut d = import(&package(&content, Some(styles))).unwrap();
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
assert_eq!(keeps(&d), [false, true]);
if let Some(wordcraft_doc::Block::Table(t)) = d.body.get_mut(0).map(std::sync::Arc::make_mut)
&& let Some(wordcraft_doc::Block::Para(p)) = t.rows[0].cells[0].blocks.get_mut(0).map(std::sync::Arc::make_mut)
{
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
assert_eq!(keeps(&import(&export(&d).unwrap()).unwrap()), [false, true], "after an edit and a save");
}
@@ -287,6 +287,97 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
);
}
/// A watermark added, changed or removed in WordCraft reaches a file's page definitions.
#[test]
fn a_watermark_change_to_a_file_is_saved() {
// A file's own page definitions were written back whatever happened to its watermark, so one
// added, changed or removed in WordCraft was gone from the saved file.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#
);
let content =
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
let plain = crate::odt::tests::package(&content, Some(&styles));
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
let shape = "PowerPlusWaterMarkObject";
// Added.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), ..Default::default() });
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains(shape) && s.contains("DRAFT"), "added: {s}");
assert_eq!(import(&saved).unwrap().settings.watermark.map(|w| w.text).as_deref(), Some("DRAFT"));
// Saved again with nothing changed, it stays as the file has it.
let read = import(&saved).unwrap();
let again = styles_of(&read);
assert_eq!(again.matches(shape).count(), s.matches(shape).count(), "{again}");
// Changed.
let mut d = import(&saved).unwrap();
if let Some(w) = d.settings.watermark.as_mut() {
w.text = "COPY".into();
}
let s = styles_of(&d);
assert!(s.contains("COPY") && !s.contains("DRAFT"), "changed: {s}");
// Removed.
let mut d = import(&saved).unwrap();
d.settings.watermark = None;
let s = styles_of(&d);
assert!(!s.contains(shape) && !s.contains("DRAFT"), "removed: {s}");
// One with no text isn't written, so it's no change to the file's page definitions.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: " ".into(), ..Default::default() });
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && !s.contains(shape), "no text: {s}");
}
/// Different Odd & Even Pages turned on or off in WordCraft reaches a file's page definitions.
#[test]
fn different_odd_and_even_pages_changed_in_a_file_is_saved() {
// A file's own page definitions were written back whatever happened to this setting, so the
// left pages' header stayed after it was turned off.
let ns = crate::odt::tests::NS_T;
let file = |left: &str| {
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/><style:header-style><style:header-footer-properties fo:min-height="1cm"/></style:header-style></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Right head</text:p></style:header>{left}</style:master-page></office:master-styles></office:document-styles>"#
);
let content = format!(
"<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"
);
crate::odt::tests::package(&content, Some(&styles))
};
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
// On in the file, and nothing changed: the file's own.
let both = file("<style:header-left><text:p>Left head</text:p></style:header-left>");
let d = import(&both).unwrap();
assert!(d.settings.even_odd_headers);
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && s.contains("Left head"), "as read: {s}");
// Turned off.
let mut d = import(&both).unwrap();
d.settings.even_odd_headers = false;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(!s.contains("<style:header-left") && s.contains("Right head"), "off: {s}");
assert!(!import(&saved).unwrap().settings.even_odd_headers);
// Off in the file, and turned on: left pages have their own header, an empty one so far.
let mut d = import(&file("")).unwrap();
assert!(!d.settings.even_odd_headers);
d.settings.even_odd_headers = true;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains("<style:header-left") && s.contains("Right head"), "on: {s}");
assert!(import(&saved).unwrap().settings.even_odd_headers);
}
#[test]
fn libreoffices_watermark_reads_as_the_watermark() {
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
+27 -13
View File
@@ -161,19 +161,33 @@ impl Writer {
out.push_str("<table:table-header-rows>");
}
// A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`).
match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, exact)) => {
let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) };
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
None => out.push_str("<table:table-row>"),
let height = match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, true)) => format!(" style:row-height=\"{}\"", pt(h)),
Some((h, false)) => format!(" style:min-row-height=\"{}\"", pt(h)),
None => String::new(),
};
// A row that doesn't break across pages (§20.247 on a row). One that may says so where
// the file's default row style, which stays, would keep it together.
let keep = if t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)) {
" fo:keep-together=\"always\""
} else if self.kept_defs.get("common~style:default-style~table-row").is_some_and(|d| d.contains("keep-together") && d.contains("always"))
{
" fo:keep-together=\"auto\""
} else {
""
};
let props = format!("{height}{keep}");
if props.is_empty() {
out.push_str("<table:table-row>");
} else {
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
let mut g = 0usize;
for c in row {
+10 -5
View File
@@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
i = end;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
if r.skip_chars > 0
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl)
&& !matches!(word, "par" | "pard" | "cell" | "row")
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
{
r.skip_chars -= 1;
first_in_group = false;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
r.word(word, param, starred, first_in_group);
first_in_group = false;
starred = false;
continue;
}
if r.skip_chars > 0 && n != b'\'' {
r.skip_chars -= 1;
i += 2;
first_in_group = false;
continue;
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
+56
View File
@@ -37,6 +37,62 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
(br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"),
(br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"),
(br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"),
(br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"),
(br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"),
(br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233X}", "éX"),
(br"{\rtf1\uc1\u233?X}", "éX"),
(br"{\rtf1{\uc2147483647\u233?}X}", "éX"),
(br"{\rtf1\uc-1\u233X}", "éX"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_info_words_preserve_following_metadata() {
for (rtf, title, body) in [
(br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"),
(br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.core.title, title, "{rtf:?}");
assert_eq!(doc.core.creator, "Alice", "{rtf:?}");
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_in_revision_authors_counts_control_words() {
for (rtf, expected) in [
(br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"),
(br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}");
}
}
#[test]
fn paragraph_borders() {
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
+1 -1
View File
@@ -89,7 +89,7 @@ impl Reader {
}
self.skip_chars = self.st.uc;
}
"uc" => self.st.uc = pv.clamp(0, 8) as usize,
"uc" => self.st.uc = pv.max(0) as usize,
"plain" => {
self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() };
self.st.rev = RevSt::default();
+86 -4
View File
@@ -59,9 +59,9 @@ pub(crate) struct Ctx<'a> {
pub(crate) pin: Option<Pin>,
pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec<WrapArea>>,
pub(crate) header_tables: HashMap<(usize, u32, bool), Vec<Rect>>,
/// Heights of text boxes that resize to fit their text, by (story, width), worked out once
/// per layout ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
/// Heights of text boxes that resize to fit their text, by story, width and page fields,
/// worked out once per matching layout context ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32, (u32, u32, u32, u32, u64, wordcraft_doc::section::NumFormat)), Option<f32>>,
/// Each body float's latest placement: on which page and with what wrap area, when it is
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
@@ -344,7 +344,7 @@ impl Ctx<'_> {
/// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout).
fn fitted_height(&mut self, id: u32, w: f32) -> Option<f32> {
let key = (id, w.to_bits());
let key = (id, w.to_bits(), self.fields.page_key());
if let Some(h) = self.fitted.get(&key) {
return *h;
}
@@ -455,3 +455,85 @@ pub(crate) fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(
ctx.pct_base = before;
out
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
use wordcraft_doc::{PartKind, TextBody, para_block};
#[test]
fn fitted_text_box_cache_separates_page_field_contexts() {
let mut doc = Document::from_text("Parent");
let mut story_para = Paragraph::new();
story_para
.insert_object(
0,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
let id = doc.add_part(PartKind::TextBox, vec![para_block(story_para)]);
if let Some(part) = doc.parts.get_mut(&id) {
part.body = TextBody { auto_fit: true, ..Default::default() };
}
let mut p = Paragraph::new();
p.insert_object(
0,
InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
},
&Default::default(),
)
.unwrap();
let opts = LayoutOptions::default();
let mut cache = LayoutCache::new();
let mut ctx = Ctx {
doc: &doc,
cache: &mut cache,
opts: &opts,
counters: Default::default(),
fields: FieldCtx::default(),
notes_hash: 0,
numbers: Default::default(),
eq_count: 0,
boxes: Default::default(),
chains: Default::default(),
grid: Default::default(),
hide_deleted: opts.hides_deleted(StoryRef::Body),
uses_totals: false,
track_totals: false,
pct_base: None,
quiet_marks: Vec::new(),
stacked_marks: Vec::new(),
mirror: false,
table_keys: Vec::new(),
pin: None,
footer_wraps: Default::default(),
header_tables: Default::default(),
fitted: Default::default(),
floats_above: Default::default(),
hf: Arc::new(HfLinks::new(doc.sections())),
alphas: Default::default(),
header_repeats: 0,
};
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 1);
ctx.fields.page_format = wordcraft_doc::section::NumFormat::CardinalText;
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "the same box with a different PAGE format needs its own height");
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "matching field context reuses the fitted height");
}
}
+2
View File
@@ -463,6 +463,8 @@ fn anchor_floats_but(
late: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
ctx.fields.page = pb.number;
ctx.fields.page_format = pb.page_format;
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web && place {
+1
View File
@@ -204,6 +204,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
let fields = ctx.fields.clone();
let grid = std::mem::take(&mut ctx.grid);
let track = std::mem::replace(&mut ctx.track_totals, false);
ctx.fields.page_format = sect.page_num_format;
ctx.fields.page = page.number;
let mut areas = again(ctx, 0, |ctx| {
with_pct_base(ctx, base, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame)))).table_areas
+2
View File
@@ -9,6 +9,8 @@ use super::{Block, Border, FloatAlign, Page, ParaLayout, Rect, SectionProps, Sto
pub(crate) struct PageBuilder<'a> {
pub(crate) pages: Vec<Page>,
pub(crate) sect: &'a SectionProps,
/// The current document section's numbering format, even when reflow uses first-section page geometry.
pub(crate) page_format: wordcraft_doc::section::NumFormat,
pub(crate) sect_idx: usize,
pub(crate) col: usize,
pub(crate) cols: Vec<(f32, f32)>,
+21 -3
View File
@@ -43,6 +43,7 @@ struct Tops {
struct Sec<'p> {
si: usize,
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
start: SectionStart,
tops: Tops,
}
@@ -158,10 +159,17 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
s
}
fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> {
fn new_page_builder<'p>(
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
early: &'p EarlyFloats,
web: bool,
settle: bool,
) -> PageBuilder<'p> {
PageBuilder {
pages: Vec::new(),
sect,
page_format,
sect_idx: 0,
col: 0,
cols: Vec::new(),
@@ -221,6 +229,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
.iter()
.map(|(_, s)| {
(!web && s.text_direction.is_turned()).then(|| {
ctx.fields.page_format = s.page_num_format;
let top = body_top_for(ctx, s, s.headers.default);
vertical::unturned_sect(s, top)
})
@@ -232,6 +241,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
/// previous section's, and take its room. `fixed`: the margins are the room (continuous views and
/// turned sections).
fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fixed: bool) -> Tops {
ctx.fields.page_format = sect.page_num_format;
let (heads, feet) = (hf.heads.set(si), hf.feet.set(si));
let hb = |i: Option<u32>| hf.defining_width(false, si, i);
let fb = |i: Option<u32>| hf.defining_width(true, si, i);
@@ -246,6 +256,8 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
pb.sect = sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.sect_idx = si;
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
if !web && !doc.footnote_separators.is_empty() {
@@ -263,6 +275,8 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
fn end_previous_section<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<'p>, si: usize, block: usize) {
let Some((_, prev)) = si.checked_sub(1).and_then(|k| hf.sections.get(k)) else { return };
pb.sect = prev;
pb.page_format = prev.page_num_format;
ctx.fields.page_format = prev.page_num_format;
pb.sect_idx = si - 1;
let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1));
let top = body_top_for(ctx, prev, ph.default);
@@ -281,6 +295,8 @@ fn start_section_page<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<
if filler && !mode.web {
end_previous_section(ctx, pb, hf, sec.si, block);
pb.sect = sec.sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.sect_idx = sec.si;
}
if restart.is_some() {
@@ -492,7 +508,7 @@ pub(crate) fn layout_pass(
let first_sect = sections.first().map_or(&default_sect, |(_, s)| *s);
let web_sect = mode.reflow.then(|| reflow_sect(first_sect, opts));
let sect_ref = web_sect.as_ref().unwrap_or(first_sect);
let mut pb = new_page_builder(sect_ref, early, mode.web, !may_rewrap && !mode.reflow);
let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, early, mode.web, !may_rewrap && !mode.reflow);
let mut endnotes = endnote_queue(&ctx);
// Text boxes laid out in balancing attempts that were thrown away.
let mut boxes_discarded = 0usize;
@@ -500,15 +516,17 @@ pub(crate) fn layout_pass(
let turned = turned_sections(&mut ctx, sections, mode.web);
let mut prev_turn = TextDirection::Horizontal;
for (si, (end, sect)) in sections.iter().enumerate() {
let page_format = sect.page_num_format;
let turn = if mode.reflow { TextDirection::Horizontal } else { sect.text_direction };
let sect: &SectionProps = match turned.get(si) {
Some(Some(t)) => t,
_ if mode.reflow => sect_ref,
_ => sect,
};
ctx.fields.page_format = page_format;
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
let sec = Sec { si, sect, start, tops };
let sec = Sec { si, sect, page_format, start, tops };
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
// The page the section before ends on is finished with its own margins and footnote
// separators, before this section's replace them: the footnotes pending on it
+90
View File
@@ -626,3 +626,93 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
}
}
/// A body PAGE field shows the page number in the section's number format, as Word does (a
/// cardinal-text section shows "one"), and `\*` overrides it.
#[test]
fn body_page_field_uses_the_section_number_format() {
let mut d = Document::from_text("Page \nPage ");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() {
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
let off = p.len();
let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &Default::default()).unwrap();
}
let l = lay(&d);
let shown: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>()) } else { None })
.collect();
assert_eq!(shown, ["one", "ONE"], "{shown:?}");
}
#[test]
fn reflow_page_field_uses_the_current_section_number_format() {
let mut d = Document::from_text("First section\nSecond section");
let first = d.last_section.clone();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
for view in [ViewMode::Web, ViewMode::Draft, ViewMode::Read, ViewMode::Outline] {
let mut cache = LayoutCache::new();
let layout = layout(&d, &mut cache, &LayoutOptions { view, ..Default::default() });
let shown: Vec<_> = layout
.pages
.iter()
.flat_map(|page| {
page.items.iter().filter_map(move |item| {
if let Placed::Lines { para, path, .. } = item {
(path == &Path::top(1)).then(|| (page.number, para.shown.iter().map(|x| x.1.as_str()).collect::<String>()))
} else {
None
}
})
})
.collect();
assert!(!shown.is_empty(), "{view:?}: no second-section text");
for (number, text) in shown {
let expected = wordcraft_doc::section::NumFormat::CardinalText.format(number).to_lowercase();
assert!(text.ends_with(&expected), "{view:?}, page {number}: {text:?}, expected {expected:?}");
}
}
}
/// A PAGE field in a table that opens the body uses the section's number format too: the table
/// is placed without the paragraph path that sets it.
#[test]
fn table_cell_page_field_uses_the_section_number_format() {
use wordcraft_doc::Table;
let mut d = Document::from_text("after");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let mut t = Table::new(1, 1, 200.0);
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(0, field, &Default::default()).unwrap();
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![para_block(p)];
}
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let l = lay(&d);
let shown: String = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let Placed::Lines { para, path, .. } = it {
(path.0.len() > 1).then(|| para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect();
assert_eq!(shown, "one");
}
+2
View File
@@ -13,6 +13,8 @@ use super::{
mod commands;
mod control;
mod files;
#[cfg(test)]
pub(crate) use files::suggested_stem;
mod fonts;
mod frame;
mod input;
+37 -3
View File
@@ -298,12 +298,16 @@ impl WordApp {
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx.
/// otherwise .docx. A document never saved and without a title of its own is named after its
/// first words, as Word suggests them (#5793), instead of `Document1`.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
_ => format!("{}.docx", self.title_stem()),
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
_ => format!("{stem}.docx"),
}
}
@@ -463,3 +467,33 @@ impl WordApp {
}
}
}
/// The most characters a name taken from the text keeps ([`suggested_stem`]).
const SUGGESTED_MAX: usize = 50;
/// A file name from a paragraph's text (#5793): up to its first sentence or clause end
/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a
/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most
/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows
/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used.
pub(crate) fn suggested_stem(text: &str) -> Option<String> {
let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':');
let head = text.split(end).next().unwrap_or("");
let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect();
let mut words = String::new();
for w in kept.split_whitespace() {
let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() };
if next > SUGGESTED_MAX {
if words.is_empty() {
words = w.chars().take(SUGGESTED_MAX).collect();
}
break;
}
if !words.is_empty() {
words.push(' ');
}
words.push_str(w);
}
let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
(!words.is_empty()).then_some(words)
}
+12 -1
View File
@@ -55,6 +55,17 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left
pos2(band_min_x + x, center_y)
}
/// The bottom of the backstage menu: About, and the community link unless the user turned the
/// community button off — the title bar pill honors the same setting, so the menu hides the
/// link with it.
fn bottom_links(app: &WordApp) -> Vec<(&'static str, &'static str)> {
let mut links = vec![("About", "about")];
if app.ui.show_discord {
links.push(("Discord community", "discord"));
}
links
}
/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open
/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
@@ -155,7 +166,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
for (label, id) in bottom_links(app) {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
if resp.hovered() {
+23
View File
@@ -90,6 +90,24 @@ const TILE_HEIGHT_PX: usize = 1024;
/// are rendered again when they come back.
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
/// The document's scroll bars (#5746): solid, so they show wherever the pointer is (egui's
/// floating ones are invisible until the pointer nears them), and with their margin exactly as
/// wide as the [`SCROLLBAR`] room the pages leave, so they neither cover nor move the pages.
fn canvas_scroll_style() -> egui::style::ScrollStyle {
let solid = egui::style::ScrollStyle::solid();
egui::style::ScrollStyle { bar_width: SCROLLBAR - solid.bar_inner_margin - solid.bar_outer_margin, ..solid }
}
/// The document's scroll bar thumb (#5746): egui paints a solid bar's track in
/// `extreme_bg_color` and its thumb in the widget fills, which our palette sets alike (`input`), so
/// the thumb would vanish into its track. Grey at rest, darker (lighter in the dark theme) under
/// the pointer and while dragged. Only the scroll area's own visuals: the pages' contents keep theirs.
fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) {
visuals.widgets.inactive.bg_fill = t.border_strong;
visuals.widgets.hovered.bg_fill = t.input_border;
visuals.widgets.active.bg_fill = t.text_dim;
}
pub struct CanvasState {
/// Page rasters: (key, what the page showed, texture). The key ([`page_key`]) includes the
/// raster scale; what the page showed ([`page_shows`]) doesn't, so a zoom can tell whether
@@ -1478,9 +1496,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let CanvasView { area, hruler, vruler, paper, zooming, hinted, geo, dim_body, grid_story, caret, .. } = view;
let mut origin = area.min;
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
let inherited = std::sync::Arc::clone(ui_area.style());
ui_area.spacing_mut().scroll = canvas_scroll_style();
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
let mut ime_geometry: Option<(Rect, Rect)> = None;
let mut ime_interrupt = false;
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
ui.set_style(inherited);
origin = ui.min_rect().min - viewport.min.to_vec2();
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
let resp = canvas_response(ui, content, scroll_target);
+80
View File
@@ -755,6 +755,86 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
}
}
/// #5746: the document's scroll bars are drawn wherever the pointer is: before it has moved and
/// with it over the page, not only at the window's edge, with a thumb that stands out from its
/// track in the light and the dark theme. The vertical one fills the room the pages leave for it,
/// so it covers none of them; zoomed in, the horizontal one shows the same way.
#[test]
fn the_document_scroll_bars_show_with_the_pointer_away() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let bar = |h: &H, vertical: bool| {
h.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| (r.height() > r.width()) == vertical)
.max_by(|a, b| a.area().total_cmp(&b.area()))
};
// The opaque fills across the bar's whole width, longest first: its track, then its thumb.
let drawn = |h: &H, bar: Rect, vertical: bool| {
let across = |r: &Rect| if vertical { r.width() } else { r.height() };
let along = |r: &Rect| if vertical { r.height() } else { r.width() };
let mut fills: Vec<(f32, egui::Color32)> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill.a() > 0 && bar.expand(0.5).contains_rect(r.rect) && across(&r.rect) >= across(&bar) - 0.5 => {
Some((along(&r.rect), r.fill))
}
_ => None,
})
.collect();
fills.sort_by(|a, b| b.0.total_cmp(&a.0));
fills
};
// The thumb shows on its track: a clear step in grey, not the same fill.
let thumb_shows = |fills: &[(f32, egui::Color32)], what: &str| {
let [(track_len, track), (thumb_len, thumb), ..] = fills else { panic!("{what}: a track and a thumb are drawn: {fills:?}") };
assert!(thumb_len < track_len, "{what}: the thumb is shorter than its track: {fills:?}");
let step = (0..3).map(|i| (i32::from(track[i]) - i32::from(thumb[i])).abs()).max().unwrap_or(0);
assert!(step >= 40, "{what}: the thumb {thumb:?} stands out from its track {track:?}");
};
for theme in ["light", "dark"] {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..3 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("ui.theme", json!({"value": theme})).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(Tokens::get(&h.ctx).dark, theme == "dark", "the {theme} theme is on");
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, before the pointer has moved"));
let viewport = h.state().canvas.viewport.expect("the canvas is shown");
assert!((v.right() - viewport.right() - SCROLLBAR).abs() < 0.5, "the bar fills the room left for it: {v:?} beside {viewport:?}");
assert!(h.state().canvas.page_rects.iter().all(|p| p.right() <= v.left()), "the bar covers no page");
let page = h.state().canvas.page_rects.first().copied().expect("a page on screen");
h.event(egui::Event::PointerMoved(page.center()));
for _ in 0..30 {
h.step();
}
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, the pointer over the page"));
h.state_mut().run("view.zoom", json!({"value": 400.0})).unwrap();
for _ in 0..30 {
h.step();
}
let across = bar(&h, false).expect("zoomed in, a horizontal scroll bar");
thumb_shows(&drawn(&h, across, false), &format!("{theme}, the horizontal bar"));
}
}
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
/// is the page at the top of the window; while the caret is on screen, its page.
#[test]
+16 -4
View File
@@ -581,8 +581,20 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
/// A margin's number box, in the unit the interface measures in.
fn margin(value: &mut f32) -> egui::DragValue<'_> {
egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)
fn margin(value: &mut f32) -> impl egui::Widget + '_ {
move |ui: &mut Ui| {
// DragValue's unfocused button extends to fit its value; keep it inside the field too.
let (_, rect) = ui.allocate_space(egui::vec2(80.0, ui.spacing().interact_size.y));
let mut field = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(crate::rtl::row_layout(ui, egui::Align::Center)));
field.shrink_clip_rect(rect);
field.add(
egui::DragValue::new(value)
.speed(3.6 / crate::options::unit().pt_per_unit())
.measure(crate::options::unit())
.max_decimals(2)
.clip_text(true),
)
}
}
fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
@@ -640,9 +652,9 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| {
t.row(|r| {
let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))));
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id));
r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000).clip_text(true)).labelled_by(chars_label.id));
let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))));
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id));
r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000).clip_text(true)).labelled_by(lines_label.id));
});
});
ui.horizontal(|ui| {
+3 -2
View File
@@ -112,12 +112,13 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &
t.row(|r| {
let width_label = r.cell(|ui| ui.label(tl!(l.width)));
let w = r.cell(|ui| {
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2))
ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2).clip_text(true))
.labelled_by(width_label.id)
});
let height_label = r.cell(|ui| ui.label(tl!(l.height)));
let h = r.cell(|ui| {
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id)
ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2).clip_text(true))
.labelled_by(height_label.id)
});
if (w.changed() || h.changed()) && paper.0 != paper.1 {
*landscape = paper.0 > paper.1;
+139
View File
@@ -985,6 +985,145 @@ fn opened_paper(app: &mut WordApp) -> ((f32, f32), Option<&'static str>) {
((paper.0 * ppu, paper.1 * ppu), paper_entry(paper))
}
/// #5650: a long paste scrolls inside Page Setup's number boxes instead of enlarging the
/// dialog, including after leaving the field and reopening the dialog.
#[test]
fn page_setup_number_fields_do_not_expand_after_paste() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
for label in ["Bottom:", "Top:", "Left:", "Right:", "Width:", "Height:", "Characters per line:", "Lines per page:"] {
let mut h = app_harness(app_with("Page"));
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
if let Some(Dialog::PageSetup { grid, .. }) = h.state_mut().dialog.as_mut() {
grid.0 = 2; // Enable both document-grid number boxes.
}
h.run_steps(3);
let width =
|h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width();
let before = width(&h);
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste("a".repeat(400)));
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: pasted text grew the dialog from {before} to {}", width(&h));
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: leaving the field kept a larger dialog");
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
h.run_steps(3);
assert!(width(&h) <= before + 2.0, "{label}: reopening kept a larger dialog");
}
}
/// A finite value can outgrow DragValue's unfocused button even when its editor clips.
#[test]
fn page_setup_large_numeric_margins_do_not_expand_after_leaving_field() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
for label in ["Bottom:", "Top:", "Left:", "Right:"] {
let mut h = app_harness(app_with("Page"));
h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
h.run_steps(3);
let width =
|h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap().width();
let before = width(&h);
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste(format!("1{}", "0".repeat(36))));
h.run_steps(3);
let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") };
let value = match label {
"Bottom:" => *bottom,
"Top:" => *top,
"Left:" => *left,
_ => *right,
};
assert!(value.is_finite() && value > 1e35, "{label}: the large finite paste was accepted: {value}");
assert!(width(&h) <= before + 2.0, "{label}: editing grew the dialog from {before} to {}", width(&h));
h.key_press(egui::Key::Tab);
h.run_steps(3);
let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state().dialog.as_ref() else { panic!("no page setup dialog") };
let after = match label {
"Bottom:" => *bottom,
"Top:" => *top,
"Left:" => *left,
_ => *right,
};
assert_eq!(after, value, "{label}: leaving the field keeps the accepted value");
assert!(width(&h) <= before + 2.0, "{label}: leaving the large numeric field grew the dialog from {before} to {}", width(&h));
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
}
}
/// A clipped wide number in a mirrored form can still be clicked where it is visible.
#[test]
fn page_setup_rtl_large_margin_can_be_edited_again_after_blur() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = app_with("Page");
app.run("ui.dialog", json!({"name": "pageSetup"})).unwrap();
let ar = crate::i18n::fixture_lang("ar", "\tMargins\tMargins\n");
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
move |ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
// Draw the actual dialog in a fixture language even without embedded catalogs.
crate::i18n::set_current(ar);
crate::rtl::sync(&ctx);
assert!(crate::rtl::on() && ctx.global_style().right_to_left);
show(app, &ctx);
crate::i18n::set_current(crate::i18n::Lang::EN);
},
app,
);
h.run_steps(4);
let initial = h.get_by_role_and_label(Role::SpinButton, "Bottom:").rect();
assert!(h.get_by_role_and_label(Role::SpinButton, "Top:").rect().center().x > initial.center().x, "the form is mirrored");
let window = |h: &egui_kittest::Harness<'_, WordApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Page Setup")))).unwrap();
let before = window(&h).width();
h.get_by_role_and_label(Role::SpinButton, "Bottom:").click();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste(format!("1{}", "0".repeat(36))));
h.run_steps(3);
let value = match h.state().dialog.as_ref() {
Some(Dialog::PageSetup { bottom, .. }) => *bottom,
_ => panic!("no page setup dialog"),
};
assert!(value.is_finite() && value > 1e35);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == value));
let field = h.get_by_role_and_label(Role::SpinButton, "Bottom:");
assert!(!field.is_focused());
let wide = field.rect();
let visible = wide.intersect(initial).intersect(window(&h));
assert!(wide.width() > 80.0 && visible.is_positive() && !visible.contains(wide.center()));
assert!(window(&h).width() <= before + 2.0);
// The AccessKit bounds include clipped text: click the visible part, not their centre.
let pos = visible.center();
h.event(egui::Event::PointerMoved(pos));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
assert!(h.get_by_role_and_label(Role::SpinButton, "Bottom:").is_focused(), "the visible field enters editing");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Paste("2.5".into()));
h.run_steps(3);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(matches!(h.state().dialog.as_ref(), Some(Dialog::PageSetup { bottom, .. }) if *bottom == 2.5), "the field accepts a normal value again");
assert!(window(&h).width() <= before + 2.0);
}
/// #1433: Page Setup's Paper size shows the named size the section has, in either
/// orientation, and Custom size (None) for anything else.
#[test]
+2 -1
View File
@@ -315,7 +315,8 @@ fn save_as_answer_arriving_later_is_saved() {
typed(&mut a);
a.save_as_dialog();
assert!(a.file_dialog_open());
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None });
// A new document is offered its first words as its name, as in Word (#5793).
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{UNSAVED}.docx"), dir: None });
let ctx = egui::Context::default();
let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
frame(&mut a);
+68
View File
@@ -96,6 +96,55 @@ fn title_stem_omits_nul_from_each_title_source() {
}
}
/// #5793: a name from a paragraph's first words, as Word suggests one: up to the first sentence or
/// clause end, without what a file name can't hold, at most 50 characters at a word's end.
#[test]
fn a_suggested_name_is_the_first_words_of_the_text() {
use crate::app::suggested_stem as stem;
assert_eq!(stem("Quarterly report for the board. More follows here.").as_deref(), Some("Quarterly report for the board"));
assert_eq!(stem("Minutes of the meeting, 6 November").as_deref(), Some("Minutes of the meeting, 6 November"));
assert_eq!(stem("What happened? Nobody knows").as_deref(), Some("What happened"));
assert_eq!(stem("Plan: phase one").as_deref(), Some("Plan"));
assert_eq!(stem("Q3 / Q4 \"draft\" <final> *v2* | a\\b").as_deref(), Some("Q3 Q4 draft final v2 a b"));
assert_eq!(stem(" Leading and inner spaces ").as_deref(), Some("Leading and inner spaces"));
assert_eq!(stem("日本語の文書。続きの文").as_deref(), Some("日本語の文書"));
let long = "Annual report of the regional committee on transport and infrastructure planning";
let got = stem(long).unwrap();
assert!(got.chars().count() <= 50 && long.starts_with(&got) && long[got.len()..].starts_with(' '), "{got:?}");
assert_eq!(stem(&"x".repeat(80)).map(|s| s.chars().count()), Some(50), "one long word is cut at 50");
for nothing in ["", " ", "...", "?", "/ * |", "\u{1}"] {
assert_eq!(stem(nothing), None, "{nothing:?}");
}
}
/// #5793: Save As suggests the first words for a document that was never saved and has no title
/// of its own (the first paragraph with text, past empty ones); a title, a file name or a path
/// still name it.
#[test]
fn save_as_suggests_the_first_words_of_a_new_document() {
let mut app = app();
assert_eq!(app.default_save_name(), format!("{}.docx", app.title_stem()), "an empty document");
for c in [
json!(["text.newParagraph", {}]),
json!(["text.insert", {"text": "Minutes of the board meeting. Present: all"}]),
json!(["text.newParagraph", {}]),
json!(["text.insert", {"text": "Second paragraph"}]),
] {
app.session.run(c[0].as_str().unwrap(), &c[1]).unwrap();
}
assert_eq!(app.default_save_name(), "Minutes of the board meeting.docx");
assert_eq!(
app.title_stem(),
crate::documents::untitled_title(&app.session.resolved_filenames().0).unwrap_or_else(|| "Document1".into()),
"the window title stays Document1"
);
app.session.doc.core.title = "Board minutes".into();
assert_eq!(app.default_save_name(), "Board minutes.docx", "a title wins");
app.session.doc.core.title.clear();
app.session.path = Some(std::path::PathBuf::from("/tmp/kept.docm"));
assert_eq!(app.default_save_name(), "kept.docm", "a saved file keeps its name and format");
}
/// #1678: Focus and Full Screen View put the window in full screen, asked of the system once
/// per change (switching between the two keeps it), and the usual window comes back after.
#[test]
@@ -541,6 +590,25 @@ fn title_and_account_buttons_work() {
assert!(h.query_by_label("User name:").is_some());
}
/// The Options toggle hides the community pill in the title bar; the backstage menu's
/// Discord link hides with it — one setting, everywhere the link shows.
#[test]
fn the_community_button_off_hides_the_backstage_discord_link_too() {
use egui_kittest::kittest::Queryable;
let mut a = app();
a.run("ui.backstage", json!({"value": true})).unwrap();
let mut h = harness(a);
h.run_steps(2);
assert!(h.query_by_label("Discord community").is_some(), "the community link shows by default");
let mut a = app();
a.ui.show_discord = false;
a.run("ui.backstage", json!({"value": true})).unwrap();
let mut h = harness(a);
h.run_steps(2);
assert!(h.query_by_label("Discord community").is_none(), "the backstage link honors the same setting");
}
/// File › Options › Advanced › Editing: alignment guides are a UI command, on by default and
/// kept between runs.
#[test]
+5 -3
View File
@@ -1,6 +1,6 @@
# WordCraft architecture
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-02 · **Change:** trivial (craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-11-05 · **Change:** trivial (the hostile-parameter test now has a sibling for `file.*`, `insert.picture` and `insert.textFromFile`; previously trivial: craft-proof's row says what a build without the Language data keeps: English spelling from an installed Hunspell English or the built-in list, rule hyphenation for Bulgarian, Turkish and Russian; previously minor: proofing moved out: `wordcraft-proof` is craft-libs' `craft-proof`, its Language data craft-i18n's `language-data/` embedded from `CRAFT_I18N_DIR`; previously major: crate and layer inventory, source counts, proofing languages and module boundaries re-measured from this checkout; previously minor: `proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
How WordCraft is built today. The longer design notes in
`plan/architecture.md` are gitignored and local to the original author's machine; this file is the
@@ -57,8 +57,9 @@ crate name links to its source tree.
- 821 `#[test]` functions and 10 property-test blocks: model, layout, DOCX round-trip and hostile
input, `.doc`, formats, typing parity (`engine/src/tests/typing.rs`), bidi, i18n catalogs, UI
interaction (egui_kittest), MCP acceptance; `hostile_params_never_panic` runs every command with
junk params.
interaction (egui_kittest), MCP acceptance; `hostile_file_params_never_panic` runs `file.*`,
`insert.picture` and `insert.textFromFile` with junk params and unusable paths, held to a
temporary folder, and `hostile_params_never_panic` every other command with junk params.
- `cargo xtask ci`: fmt, clippy `-D warnings`, tests, asset attribution, layering, wasm build.
- `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md); a test keeps the
catalog percentage above a floor that only rises.
@@ -69,6 +70,7 @@ crate name links to its source tree.
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | `hostile_file_params_never_panic` runs `file.*`, `insert.picture` and `insert.textFromFile` with junk params and unusable paths (#3719) |
| 2026-11-02 | trivial | craft-proof's row: without the Language data English spelling falls back to an installed Hunspell English or the built-in Moby list, and only pattern hyphenation is off (#5202 review) |
| 2026-11-01 | minor | `proof` left the workspace: it is `craft-proof` in storytold/craft-libs (with its history), and the spelling and hyphenation data is craft-i18n's `language-data/` (`CRAFT_I18N_DIR`); `assets/spelling` and `assets/hyphenation` are gone |
| 2026-10-28 | major | Recounted workspace crates, layers and Rust source lines; refreshed proofing languages and documented current engine/layout module boundaries and source links |
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username.
username. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
File diff suppressed because one or more lines are too long
+8
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -271,6 +271,7 @@ new mixed-style emoji and mixed bundled-font combining-mark controls cover LTR/b
hit stops, full selection, and narrow/overwide line wrapping. Independent review and the full
layout suite are pending; this is not a new Word/native-IME acceptance claim.
Body `PAGE` fields in paragraphs and tables use the active section's number format; section headers and footers are measured with that format before body placement. Regression coverage includes Web, Draft, Read and Outline views.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
The front-of-text opt-out is retained from the earlier implementation; this patch does not establish native Word equivalence between front-of-text and behind-text pictures. Synthetic placement tests cover the two paths, while a new native comparison of those wrap modes remains open. Turned-cell layouts disable horizontal cell confinement because their frame x axis becomes page y.
@@ -295,6 +296,8 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context |
| 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement |
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
File diff suppressed because one or more lines are too long
+9 -1
View File
File diff suppressed because one or more lines are too long