mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
@@ -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),
|
||||
)) {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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 "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ¶ms {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let r = confined(&mut s, spec.id, v);
|
||||
assert!(!caught_panic(®, &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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user