Compare commits

..
Author SHA1 Message Date
Mex 060768f1ed Preserve header/footer content when adding page numbers (#2201)
Fixes #2201. Top/bottom page numbering preserves neighboring header/footer text, formatting and tables, recognizes existing PAGE fields through nested tables/text boxes, and updates gallery-generated numbering in place.

The latest review follow-up lets refreshed PAGE/NUMPAGES result caches vary during generated-number recognition. It retains those caches and paragraph marks during format/alignment changes; identical requests remain clean and add no undo step. Field instructions, flags and paragraph/run formatting remain conservatively checked. Original history, incoming progress notes and readiness estimates are preserved.

Local validation before the integration refresh:

- Actual RED: 13 existing tests passed; the new reopened cached-PAGE fixture failed because right alignment stayed centered.
- GREEN: all **14 preservation tests** pass, with all 13 originals an exact byte prefix. The fixture checks DOCX-reopened caches 7/12, no-op cleanliness/undo, later changes, content/marks/selection and undo/redo.
- Full engine package: **1,350 passed, zero failed, one existing ignored measurement**, including the focused 14. Engine formatting and strict all-target Clippy pass.
- Synthetic DOCX coverage; native Word interoperability was not exercised. No new whole-workspace local run is claimed.

Current head: `5eb4c8cb8b205ebcfcb5b1a48d55c5e21b1683e6`, a normal preserving merge of upstream `d40d1b27181b61990c84c75f77d5fd23ef81d452`. The new roadmap and format-parity documentation conflicts retain all incoming progress and revision history plus this PR's existing page-number notes. All **1,087 published files** match incoming upstream plus the five reviewed changes; both owned Rust files remain byte-for-byte unchanged from `ca615f3` and `447dec8`, and manifests/lock match incoming upstream. No new local Rust run is claimed for this documentation-only integration. The [preceding ca615f3 full hosted gate](https://github.com/storytold/wordcraft/actions/runs/42679017335/job/129368197175?pr=2251) passed in 13m38s. The [required current-head full gate](https://github.com/storytold/wordcraft/actions/runs/42693258780/job/129418282235?pr=2251) passed in **13m06s**; SonarCloud planning/scanning also passed. Required validation is complete. Maintainer edits are enabled; FredApps re-review remains requested.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

## Summary

* **Bug Fixes**
  * Adding page numbers to headers and footers now preserves existing content and formatting, including content in tables and text boxes.
  * Existing user-owned page fields remain unchanged, while generated page numbers can be updated without disturbing surrounding content.
  * Repeating an identical page-number request no longer changes the document’s state or undo history.
* **Documentation**
  * Updated page-numbering guidance to reflect these preservation behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 09:06:18 +01:00
FredApps 45db128c15 fix(wordcraft): satisfy clippy on clipboard.rs after #5441
#5441 (92e0e67f) landed a Wayland clipboard size check in `apps/wordcraft/src/clipboard.rs` that left two clippy `-D warnings` failures on main:

- an unused `self` import in the Unix `platform` module (`use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture}`)
- `items_after_test_module`: `platform::picture()` was defined after `mod tests`

This removes the unused import and moves `picture()` above the test module. Behaviour is unchanged.

Local checks: `cargo fmt -p wordcraft` and `cargo clippy -p wordcraft --all-targets --locked -- -D warnings` on Linux.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
  * No user-visible changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 08:51:03 +01:00
FredApps 118eb8af1f Merge pull request #6033 from batistalucasv/fix/6011-docx-equation-revision-marks
DOCX: save and load tracked revision marks around equations (#6011)
2026-11-11 07:59:24 +01:00
FredApps 30c1279fd8 Merge pull request #6047 from vangel76/fix/5922-doc-header-empty-para
.doc: first body line no longer starts too low under a header
2026-11-11 07:58:20 +01:00
FredApps f936270317 Merge pull request #6049 from vangel76/fix/6018-line-numbers-columns
Line numbers appear beside every column and keep their Word formatting
2026-11-11 07:56:26 +01:00
Martin Ball 0fab74aa38 #4501 follow-up: restore pre-refactor behaviour
#4501 split functions for `clippy::cognitive_complexity` and was meant to change no behaviour. A post-merge review of the whole diff found four places where it did. This PR restores the old behaviour in each.

- **Layout (`crates/layout/src/pass.rs`).** From section 2 on, headers, footers and footnote separators were measured under the previous section's document grid, with its SECTION/SECTIONPAGES values. Before #4501 the grid was reset first. With a docGrid line pitch in section 1 (common in CJK documents), section 2's body top moved and pages could break differently. The order is now:
  1. `begin_section_state`: reset the grid, set the fields and separators.
  2. `section_tops`: measure the header and footer room.
  3. `apply_section_grid`: switch to the section's own grid.
  
  New regression test `a_sections_header_is_measured_without_the_previous_sections_grid`. Without the fix, section 2's body top is 265.6 pt; with it, 226.9 pt, the same as with no grid.
- **ODT (`crates/formats/src/odt/styles.rs`).** A later paragraph default-style with no font, or a mono font, wiped the default font. It is now kept, as before #4501. Covered by a test.
- **Canvas (`crates/ui-egui/src/canvas/input.rs`).** A press that was handled showed the hover-link tooltip for one frame, which the old early `return` had skipped. This covers Ctrl+click on a link, switching to the header or footer, entering an equation, and a miss. `handle_primary_press` now returns `bool`. No test.
- **Czech suggestions (`crates/proof/src/cs/suggest.rs`).** The one-letter edits are bounded by the word's letter count as typed again. That count is clamped to the lowercased length so indexing can't go out of range. #4501 had bounded them by the lowercased length, which differs for e.g. `İ`.

`cargo xtask ci` passes locally (all 8 steps, 4606 tests).


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Default document fonts remain available when a later paragraph style does not specify a usable font, and a later monospaced default does not replace an earlier proportional font.
  * Headers, footers, and note separators use the layout settings and section values of their own section, helping prevent content from overlapping.
  * Link hover tooltips are suppressed during canvas-handled clicks and return when the pointer hovers over the link after the press is released.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:32:14 +01:00
Joseph Anglada aef086976b Ribbon: hover fades, group glow, sliding tab underline and a brief content slide
## What

Builds on #3131 (the contextual tab glide); this PR is based on that branch.

- Every ribbon button and tab fades its hover in and out. Before, only some buttons did.
- Disabled buttons do not answer hover or press.
- A ribbon group glows from its bottom edge while the pointer is anywhere in it. The glow is about 9 pt tall and fades out upwards and towards both sides. It fades in and out with the pointer.
- The tab underline slides to the tab you choose, with the same ease as the contextual tab glide.
- The ribbon content fades and slides in (14 pt, 0.16 s) from the side the new tab lies on.

All motion follows the animation setting: with animations off, nothing moves and nothing fades.

## Screenshots
<img width="298" height="115" alt="image" src="https://github.com/user-attachments/assets/9b5ba886-1dcd-4260-a2dd-cecdf199e111" />
<img width="498" height="192" alt="ribbon-animated-dark" src="https://github.com/user-attachments/assets/bc43168b-183a-4ec0-875c-a9550f22fb36" />
<img width="548" height="158" alt="ribbon-animated" src="https://github.com/user-attachments/assets/4d50427e-376d-4a6a-a0bd-b1ed42fe0c9c" />

## Tests

- Pure tests for the underline slide and the tab change direction.
- `cargo test -p wordcraft-ui-egui` and clippy `-D warnings` pass.

## Related

- Overlaps with #3256 and #1420 (issue #1419): both also slide the underline and fade the content on a tab change. The hover fade and the group glow are new here. Whichever lands first, the other needs a small merge in `ribbon.rs`.
- Possibly related: issue #585.
- The Search box text centring is its own PR, #3802 (issue #3779).
- Based on #3131.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * Ribbon tabs animate as they appear and disappear, with a sliding active-tab indicator and fading, directional transitions between tab content. Departing contextual tabs remain visible briefly but cannot be selected or appear in the overflow menu.
  * Tab and overflow-button hover feedback is animated. Ribbon groups display a soft accent glow when hovered.

* **Bug Fixes**
  * Disabled buttons no longer show hover or pressed backgrounds. With animations disabled, tabs and content appear immediately.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:31:45 +01:00
vangel76andClaude Opus 5.5 bce47689ad Line numbers: number every column, and use a localised Line Number style
In a multi-column section every line number was drawn left of the page
margin, so the second column's numbers sat on top of the first column's.
Each column now has its numbers left of its own edge, as in Word.

Word in other languages saves the built-in "line number" style under a
localised id (German: Zeilennummer); the numbers now find it by its
built-in name when there is no LineNumber id, so its formatting applies.

Fixes #6018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:40:14 +01:00
vangel76andClaude Opus 5.5 5ad865831f .doc: drop the guard paragraph mark at the end of header and footer stories
[MS-DOC] 2.3.3 ends every non-empty header/footer story with a guard paragraph
mark that is not part of the story. It was read as an empty paragraph, which
added a line under every header and footer and pushed the first body line down
when the header reached past the top margin (#5922).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:18:08 +01:00
batistalucasv 0b7cfa86fe #6011: docx: strengthen tracked equation roundtrip assertions 2026-11-08 15:50:29 -03:00
batistalucasv 85eb8bb92e #6011: docx: write and read revision marks around equations 2026-11-08 15:13:02 -03:00
30 changed files with 1220 additions and 115 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -164,7 +164,7 @@ mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -348,6 +348,22 @@ mod platform {
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -369,22 +385,6 @@ mod platform {
assert!(complete(0, 0, 0));
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
}
#[cfg(target_os = "windows")]
+31 -1
View File
@@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -436,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
Ok(table::assemble(paras))
}
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
/// non-empty header story ends with a paragraph mark that only separates it from the next story
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
/// guard would add an empty paragraph to every header and footer and push the body down when
/// the header reaches past the top margin (#5922).
///
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
/// after a table row stays the paragraph that closes the table.
fn header_story_blocks(
ctx: &WalkCtx<'_>,
a: u32,
b: u32,
pics: &mut media::Pictures,
max_paragraphs: usize,
) -> Result<wordcraft_doc::Blocks, DocbinError> {
let mut paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
if let [.., prev, last] = paras.as_slice() {
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
if guard && outside_table(prev) {
paras.pop();
}
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
+52
View File
@@ -1157,6 +1157,58 @@ fn header_story_becomes_part() {
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
}
/// The odd header's blocks for a one-section document whose header story is `story`, with
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
let spec = StructSpec {
text: "body\r",
header_stories: vec![story],
sections: vec![(5, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries,
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let id = doc.last_section.headers.default?;
let part = doc.parts.get(&id).expect("part");
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
}
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
/// and pushed the first body line down when the header reached past the top margin.
#[test]
fn header_story_guard_mark_is_not_a_paragraph() {
let plain = || vec![(0, 0, Vec::new())];
// One line typed into the header: content "Header\r" + guard.
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
// An empty header (one empty content paragraph + guard) is still a header of its own.
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
// A story that is only a mark (no guard) keeps its paragraph and its part.
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
// A story without a guard keeps its last paragraph.
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
}
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
/// closes the table, not a guard to drop.
#[test]
fn header_story_mark_after_a_table_row_is_kept() {
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
}
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
/// built per the spec (cb counts the bytes after it, plus one).
fn t_def_table(edges: &[i16]) -> Vec<u8> {
+42 -15
View File
@@ -1,3 +1,5 @@
use wordcraft_doc::math::MathJc;
use super::{
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
@@ -13,6 +15,7 @@ impl Reader<'_> {
}
}
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
match k.name.as_str() {
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
@@ -76,25 +79,38 @@ impl Reader<'_> {
}
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
"m:oMath" => {
let mut props = self.run_props_none(ctx);
let mut math = crate::read::math::read_omath(k, Default::default());
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
let props = self.run_props_none(ctx);
self.emit_omath(sc, pb, k, Default::default(), false, &props);
}
"m:oMathPara" => {
let props = self.run_props_none(ctx);
let base_props = self.run_props_none(ctx);
let jc = crate::read::math::para_jc(k);
for m in k.children("m:oMath") {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
for c in k.els() {
match c.name.as_str() {
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
"w:ins" | "w:moveTo" => {
let mut sub_ctx = ctx.clone();
sub_ctx.ins =
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
"w:del" | "w:moveFrom" => {
let mut sub_ctx = ctx.clone();
sub_ctx.del = Some(if c.name == "w:moveFrom" {
self.move_revision(false, c, false)
} else {
self.revision(RevisionKind::Delete, c)
});
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
_ => {}
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
}
}
"mc:AlternateContent" => {
@@ -384,6 +400,17 @@ impl Reader<'_> {
}
}
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(color) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(color));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+3
View File
@@ -4,6 +4,7 @@ use super::section::comment_reference_style;
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
impl Writer<'_> {
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
match o {
// Written by `para_content` (inline) or `blocks` (around paragraphs).
@@ -51,10 +52,12 @@ impl Writer<'_> {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
self.rev_open(w, props);
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
None => crate::write::math::write_equation(w, linear, *display, math, color),
}
self.rev_close(w, props);
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
+31
View File
@@ -0,0 +1,31 @@
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
//! character style that formats the numbers (#6018).
use std::sync::Arc;
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
/// Every line-numbering option and a localised "line number" style (German Word calls its id
/// `Zeilennummer`) survive a save and reopen, beside two columns.
#[test]
fn line_numbering_options_and_style_round_trip() {
let mut d = Document::from_text("one\ntwo\nthree");
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
d.last_section.line_numbers = Some(numbering.clone());
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(Style {
id: "Zeilennummer".into(),
name: "Line Number".into(),
kind: StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
..Default::default()
});
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.last_section.line_numbers, Some(numbering));
assert_eq!(back.last_section.columns.count, 2);
let st = back.styles.find("line number").expect("the line number style");
assert_eq!(st.id, "Zeilennummer");
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
}
+1
View File
@@ -20,6 +20,7 @@ mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod line_numbering;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
+74
View File
@@ -994,6 +994,80 @@ fn tracked_changes_round_trip() {
assert!(r.settings.track_changes);
}
#[test]
fn tracked_equations_round_trip() {
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
d.settings.track_changes = true;
let ins = CharProps { ins: Some(0), ..Default::default() };
let del = CharProps { del: Some(1), ..Default::default() };
let plain = CharProps::default();
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
d.body = vec![para_block(p.clone())];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
assert!(
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
"del directly encloses display math: {xml}"
);
assert!(
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
"ins directly encloses display math: {xml}"
);
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(r.revisions, d.revisions);
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 5);
let eq_summary: Vec<(bool, String)> = got
.objects
.iter()
.map(|o| match o {
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
_ => panic!("expected equation object, got {o:?}"),
})
.collect();
assert_eq!(
eq_summary,
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
);
assert_eq!(got.runs, p.runs);
}
#[test]
fn nested_omath_para_revisions_read() {
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
let base = wordcraft_docx::write(&d).unwrap();
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
let r = wordcraft_docx::read(&bytes).expect("read");
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 2);
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
assert_eq!(got.runs[0].props.ins, Some(0));
assert_eq!(got.runs[1].props.del, Some(1));
}
#[test]
fn tracked_paragraph_marks_round_trip() {
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
+118 -15
View File
@@ -1,8 +1,72 @@
use super::PartKind;
use super::header_footer::hf_part;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text,
};
use std::collections::BTreeSet;
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
let Some(blocks) = doc.story(story) else { return false };
let mut seen = BTreeSet::from([story]);
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
while let Some(block) = pending.pop() {
let para = match block {
Block::Para(para) => para,
Block::Table(table) => {
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
continue;
}
};
for object in &para.objects {
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
{
return true;
}
for id in object.text_boxes() {
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
&& seen.insert(StoryRef::Part(id))
{
pending.extend(part.blocks.iter().map(|b| &**b));
}
}
}
}
false
}
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
Ok(para)
}
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
for object in &mut to.objects {
let InlineObject::Field { instr, result, .. } = object else { continue };
if let Some(cached) = from.objects.iter().find_map(|object| match object {
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
_ => None,
}) {
result.clone_from(cached);
}
}
}
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
@@ -22,22 +86,61 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
}
let header = position == "top";
let id = hf_part(s, header);
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
let story = StoryRef::Part(id);
let style = if header { "Header" } else { "Footer" };
let mut para = page_number_paragraph(style, align, x_of_y)?;
// Replace only a top-level paragraph matching our generated structure. User text,
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
if story_has_page_field(&s.doc, story, None) {
let generated = s.doc.story(story).and_then(|blocks| {
blocks.iter().enumerate().find_map(|(i, block)| {
let Block::Para(existing) = &**block else { return None };
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
[false, true]
.into_iter()
.any(|x_of_y| {
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
if p.objects.len() != existing.objects.len() {
return false;
}
p.mark = existing.mark.clone();
copy_page_number_results(&mut p, existing);
*existing == p
})
})
.then_some((i, existing))
})
});
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
// boxes. The traversal visits each story once so cycles cannot hang insertion.
if let Some((i, existing)) = generated
&& !story_has_page_field(&s.doc, story, Some(i))
{
para.mark = existing.mark.clone();
copy_page_number_results(&mut para, existing);
let replacement = para_block(para);
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
*block = replacement;
}
}
return Ok(json!({"story": id}));
}
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
let blocks = s.doc.story_mut(story)?;
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
if let [block] = blocks.as_slice()
&& let Block::Para(existing) = &**block
{
let mut placeholder = Paragraph::new().styled(style);
placeholder.mark = existing.mark.clone();
if *existing == placeholder {
para.mark = existing.mark.clone();
blocks.clear();
}
}
blocks.push(para_block(para));
Ok(json!({"story": id}))
}
@@ -0,0 +1,398 @@
//! #2201: inserting page numbers must preserve the header/footer story.
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::para::{GroupChild, InlineObject};
use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block};
use wordcraft_engine::{Selection, Session};
fn fields(doc: &Document, story: StoryRef, name: &str) -> usize {
doc.para_paths(story)
.iter()
.filter_map(|path| doc.para(story, path))
.flat_map(|p| &p.objects)
.filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name))))
.count()
}
fn field(instr: &str) -> InlineObject {
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }
}
#[test]
fn numbering_preserves_content_formatting_selection_and_undo_redo() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body text"));
let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right);
let mut table = Table::new(1, 2, 300.0);
table.rows[0].cells[0].blocks =
vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let before = s.doc.clone();
let selection = s.sel.clone();
let blocks = s.doc.story(story).unwrap().clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content");
let mut back = after.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
let reopened = StoryRef::Part(id);
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table"));
assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right));
let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") };
let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") };
assert_eq!(p.props_at(0).bold, Some(true));
assert_eq!(fields(&back, reopened, "PAGE"), 1);
assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y"));
}
}
}
}
#[test]
fn existing_page_field_in_a_table_is_preserved_with_its_switches() {
for depth in [1, 17] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let mut para = Paragraph::with_text("Existing page: ", CharProps::default());
para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap();
let mut blocks = vec![para_block(para)];
for _ in 0..depth {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = blocks;
blocks = vec![Arc::new(Block::Table(table))];
}
s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated");
}
}
#[test]
fn existing_range_page_field_is_not_duplicated() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap();
para.insert_text(para.len(), "7", &CharProps::default()).unwrap();
para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.props.align = Some(Align::Right);
let empty = para.clone();
let mut para = Paragraph::new();
para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty);
assert_eq!(fields(&s.doc, story, "PAGEREF"), 1);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
}
#[test]
fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() {
for position in ["top", "bottom"] {
let mut s = Session::new(Document::new());
let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32;
assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1);
}
let mut s = Session::new(Document::from_text("Before selected after"));
s.run("select.text", &json!({"text": "selected"})).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
assert!(s.doc.parts.is_empty());
assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1);
assert!(!s.doc.plain_text(StoryRef::Body).contains("selected"));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn existing_page_field_in_nested_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for levels in [1, 2] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
if levels == 2 {
// Imported stories can contain a nested box, even though the insert command
// does not offer one while the caret is already in a text box.
let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
if let InlineObject::Shape { story, .. } = &mut anchor {
*story = Some(inner);
}
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner)));
}
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::<usize>(), 0);
}
}
}
}
#[test]
fn existing_page_field_in_grouped_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap();
*anchor = InlineObject::Group {
w: 144.0,
h: 72.0,
float: Default::default(),
ch_w: 144.0,
ch_h: 72.0,
children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }],
canvas: None,
};
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
}
}
}
#[test]
fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1);
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn cyclic_text_box_references_do_not_block_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32;
let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn generated_numbers_switch_format_without_losing_neighboring_content() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let blocks = s.doc.story(story).unwrap().clone();
let selection = s.sel.clone();
for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
let expected = match align {
"left" => Align::Left,
"right" => Align::Right,
_ => Align::Center,
};
assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected));
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table"));
assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1);
assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0);
}
}
#[test]
fn repeated_numbering_keeps_clean_state_and_undo_depth() {
for position in ["top", "bottom"] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::new());
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
s.dirty = false;
let doc = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, doc);
assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step");
assert!(!s.dirty, "an identical request must not dirty the document");
}
}
}
#[test]
fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() };
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone();
assert_eq!(mark.bold, Some(true));
let mut s = Session::new(doc);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line");
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
let inserted = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, inserted);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
}
#[test]
fn user_owned_page_number_lookalikes_are_preserved() {
for kind in ["formatting", "metadata", "another field"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
match kind {
"formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0),
"metadata" => {
let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else {
panic!("no page field")
};
*locked = true;
}
_ => {
let mut para = Paragraph::with_text("User page: ", CharProps::default());
para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
}
}
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated");
}
}
#[test]
fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = object {
*result = if instr == "PAGE" { "7" } else { "12" }.into();
}
}
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mut s = Session::new(doc);
let cached = |s: &Session, name: &str| {
s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object {
InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()),
_ => None,
})
};
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen");
assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12"));
let kept = s.doc.story(story).unwrap()[0].clone();
let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone();
let selection = s.sel.clone();
s.dirty = false;
let before = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, before, "identical requests must retain refreshed results");
assert_eq!(s.undo_depth(), depth);
assert!(!s.dirty);
for (next_format, align) in [("x of y", "right"), ("plain", "left")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap();
assert_eq!(s.doc.story(story).unwrap()[0], kept);
assert_eq!(s.sel, selection);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left }));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y"));
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"));
if format == "x of y" && next_format == "x of y" {
assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12"));
}
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
}
}
+2 -1
View File
@@ -1177,7 +1177,8 @@ impl Styles {
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
"text-properties" if in_default && cur.is_none() => {
self.default_font = default_font(&a, &self.faces);
// A later default style without a (usable) font keeps the earlier one (#4501).
self.default_font = default_font(&a, &self.faces).or(self.default_font.take());
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
self.default_size = Some(sz.min(1638.0));
}
+30
View File
@@ -1289,6 +1289,36 @@ fn meta_creation_date_timezones_and_fallbacks() {
assert_eq!(m2.author, "Solo Creator");
}
#[test]
fn a_later_default_style_without_a_font_keeps_the_default_font() {
// #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font.
let mut styles = styles::Styles::default();
let with_font = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
);
let without = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
styles.read(with_font.as_bytes(), false);
styles.read(without.as_bytes(), false);
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
}
#[test]
fn a_later_monospaced_default_style_keeps_the_proportional_default_font() {
// #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one.
let mut styles = styles::Styles::default();
let with_font = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
);
let mono = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Courier New"/></style:default-style></office:styles></office:document-styles>"#
);
styles.read(with_font.as_bytes(), false);
styles.read(mono.as_bytes(), false);
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
}
/// The `content.xml` of a package.
pub(super) fn content_of(bytes: &[u8]) -> String {
let mut z = archive(bytes);
+8 -2
View File
@@ -7,8 +7,10 @@ use super::{
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English
/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name.
const LINE_NUMBER_STYLE: &str = "LineNumber";
const LINE_NUMBER_STYLE_NAME: &str = "line number";
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
@@ -199,7 +201,11 @@ impl Ctx<'_> {
// Word formats line numbers with the "Line Number" character style over the document's
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
// Aptos 12 pt), not in a fixed small grey.
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
let styles = &self.doc.styles;
let style = styles
.get(LINE_NUMBER_STYLE)
.or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character))
.map(|s| s.id.clone());
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
+3 -2
View File
@@ -777,7 +777,8 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec<NoteBox>, notes: &[(usize,
}
}
/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`.
/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in:
/// each column of a multi-column section gets its own (#6018), as in Word.
fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec<Placed> {
let mut items = Vec::new();
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
@@ -800,7 +801,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1):
para: num.clone(),
l0: 0,
l1: 1,
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
x: pb.col_x() - dist - w,
y: ly,
turn: TextDirection::Horizontal,
});
+28 -10
View File
@@ -270,12 +270,21 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
Tops { body, first, foot, first_foot }
}
/// Set up the walk state for a section: its grid, field values and footnote separators.
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);
/// Set up the walk state for a section before its header and footer room is measured: no grid yet
/// (the previous section's must not leak into headers, footers and separators), the field values
/// and the footnote separators.
fn begin_section_state<'p>(
ctx: &mut Ctx,
pb: &mut PageBuilder<'p>,
(si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat),
totals: &Totals,
web: bool,
) {
let doc = ctx.doc;
ctx.grid = para::Grid::default();
pb.sect = sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.page_format = page_format;
ctx.fields.page_format = 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() {
@@ -283,7 +292,11 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
}
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
ctx.fields.section_pages = totals.section(si);
ctx.grid = para::Grid::of(doc, sect);
}
/// The section's own grid and line numbering, once its header and footer room is known.
fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) {
ctx.grid = para::Grid::of(ctx.doc, sect);
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
pb.line_no = 0;
}
@@ -541,17 +554,22 @@ pub(crate) fn layout_pass(
_ 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, 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
// belong to the section whose page it is (#5289).
pb.finish_page();
}
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
// This section's fields and separators, without the previous section's grid, before its
// header and footer room is measured; its own grid once that room is known (#4501).
begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web);
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
// Measuring the room took the (reflow) section's own number format; the body keeps the
// document section's.
ctx.fields.page_format = page_format;
apply_section_grid(&mut ctx, &mut pb, sect);
let sec = Sec { si, sect, page_format, start, tops };
if mode.web && si > 0 {
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
+70
View File
@@ -4055,6 +4055,76 @@ fn line_numbers_follow_the_line_number_style() {
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
}
/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take
/// its formatting, found by the built-in name.
#[test]
fn line_numbers_follow_a_localised_line_number_style() {
let mut d = Document::from_text("one\ntwo\nthree");
d.last_section.line_numbers = Some(Default::default());
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "Zeilennummer".into(),
name: "Line Number".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
..Default::default()
});
let l = lay(&d);
let runs: Vec<_> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
_ => None,
})
.collect();
assert_eq!(runs.len(), 3);
assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}");
}
/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the
/// second column's used to be drawn in the page margin over the first column's.
#[test]
fn line_numbers_stand_beside_each_column() {
crate::test_fonts();
let text = (1..=120).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
let mut d = Document::from_text(&text);
d.last_section.line_numbers = Some(Default::default());
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
let s = d.last_section.clone();
let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0;
let l = lay(&d);
let p = &l.pages[0];
// Right edge and top of every number.
let numbers: Vec<(f32, f32)> = p
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => {
Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y))
}
_ => None,
})
.collect();
let body_xs: Vec<f32> = p
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x),
_ => None,
})
.collect();
assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}");
let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count();
let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect();
assert_eq!(right.len(), in_col2, "one number per second-column line");
// Numbers end 18 pt (auto distance) left of their column.
assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}");
let left = numbers.len() - right.len();
assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5));
assert!(left > 0);
}
#[test]
fn line_numbers_borders_text_boxes() {
crate::test_fonts();
+55
View File
@@ -627,6 +627,61 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
}
}
#[test]
fn a_sections_header_is_measured_without_the_previous_sections_grid() {
// #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header
// room is measured before section 2's grid is on, so section 1's pitch never reaches it.
let body_top = |grid: Option<wordcraft_doc::section::DocGrid>| {
let mut d = Document::from_text("First section\nSecond section");
let id = d.add_part(PartKind::Header, paras("Header line", 8));
d.last_section.headers.default = Some(id);
let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
l.pages[1].body.y
};
let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 };
let plain = body_top(None);
assert!(plain > 72.0, "the header reaches past the top margin: {plain}");
assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header");
}
#[test]
fn a_sections_header_is_measured_with_its_own_section_page_count() {
// #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three").
// Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it.
// (The total in the body makes layout run again with the page totals found.)
let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22)));
for i in 1..d.body.len() {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1);
}
let field = InlineObject::Field {
instr: r"SECTIONPAGES \* CardText".into(),
result: String::new(),
locked: false,
dirty: true,
code: false,
form: String::new(),
};
let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap();
let mut header = Paragraph::new();
for _ in 0..60 {
header.insert_object(header.len(), field.clone(), &Default::default()).unwrap();
}
let id = d.add_part(PartKind::Header, vec![para_block(header)]);
d.last_section.headers.default = Some(id);
let first = SectionProps { headers: Default::default(), ..d.last_section.clone() };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
let l = lay(&d);
assert_eq!(l.pages.len(), 24);
let p = &l.pages[1];
let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap();
assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}");
assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y);
}
/// 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]
+13 -9
View File
@@ -175,7 +175,9 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
return;
}
if pressed {
handle_primary_press(app, &pointer, mods, hf_zone, hf_enter);
if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) {
return;
}
} else if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some((from, last)) = app.canvas.column_drag
@@ -229,13 +231,14 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
show_hover_link(app, &pointer);
}
/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame).
fn handle_primary_press(
app: &mut WordApp,
pointer: &PagePointer<'_>,
mods: egui::Modifiers,
hf_zone: Option<(Option<StoryRef>, bool)>,
hf_enter: bool,
) {
) -> bool {
let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer;
let now = ui.input(|i| i.time);
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
@@ -269,14 +272,14 @@ fn handle_primary_press(
}
app.session.page_hint = page;
}
return;
return true;
}
// A press in the header/footer area from the body leaves the caret where it is, as in
// Word (#2103): moving it to the nearest body text scrolled the view there, away from the
// footer the double-click was aiming at.
if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body {
app.canvas.column_drag = None;
return;
return true;
}
if !mods.shift
&& let Some((at, inner)) = layout.equation_hit(page, x, y, story)
@@ -284,7 +287,7 @@ fn handle_primary_press(
app.session.sel = wordcraft_engine::Selection::caret(at);
let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()}));
app.session.page_hint = page;
return;
return true;
}
if app.session.math.is_some() {
let _ = app.run("equation.exit", json!({}));
@@ -324,15 +327,15 @@ fn handle_primary_press(
};
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
app.canvas.dragging = true;
return;
return true;
}
let Some(pos) = layout.hit(page, x, y, story) else { return };
let Some(pos) = layout.hit(page, x, y, story) else { return true };
// Ctrl/⌘+click follows a hyperlink.
if mods.command
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
{
follow_link(app, link);
return;
return true;
}
let pj = serde_json::to_value(&pos).unwrap_or_default();
// Alt+drag selects a column (block) of text.
@@ -341,7 +344,7 @@ fn handle_primary_press(
app.session.page_hint = page;
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
app.canvas.dragging = true;
return;
return true;
}
app.canvas.column_drag = None;
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
@@ -362,6 +365,7 @@ fn handle_primary_press(
app.session.page_hint = page;
app.canvas.dragging = true;
// Format painter applies on mouse up
false
}
fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef {
+34
View File
@@ -2887,6 +2887,40 @@ fn right_clicking_a_link_hides_the_hover_tooltip() {
assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu");
}
/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with
/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again.
#[test]
fn a_handled_press_skips_the_link_tooltip() {
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default());
a.run("select.text", json!({"text": "here"})).unwrap();
a.run("insert.link", json!({"url": "https://example.com/"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
steps(&mut h, 6);
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap();
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
h.event(Event::PointerMoved(at));
steps(&mut h, 2);
let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count();
assert!(tips(&h) > 0, "hovering the link shows its target");
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND });
h.step();
assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame");
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND });
h.event(Event::ModifiersChanged(Modifiers::NONE));
steps(&mut h, 2);
assert!(tips(&h) > 0, "hovering again shows the tooltip");
}
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
/// the scrollable range.
#[test]
+26 -6
View File
@@ -70,7 +70,10 @@ use shape::shape_format;
use table::{table_design, table_layout};
#[cfg(test)]
use tabs::MIN_TAB_W;
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
use tabs::{
CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline,
tab_content_entry, tab_entry,
};
pub use tabs::{
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
in_table_public, plan_strip, resolve_tab,
@@ -277,8 +280,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active && ghost.is_none() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none());
if hover > 0.0 {
ui.painter().rect_filled(
r.shrink2(vec2(0.0, 4.0)),
4.0,
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
);
}
// Entering and departing contextual tabs move and fade without changing layout.
let entry = match ghost {
@@ -316,7 +324,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
if active {
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate);
}
if resp.clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
@@ -337,8 +345,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.collect();
if !left_out.is_empty() {
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
if hover > 0.0 {
ui.painter().rect_filled(
r.shrink2(vec2(0.0, 4.0)),
4.0,
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
);
}
// The active tab is in the menu: the button says so.
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
@@ -376,6 +389,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
)
.show(ui, |ui| {
// Bring newly selected ribbon content in from the side of its tab.
let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0);
let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate);
let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0);
let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout()));
shifted.set_opacity(0.2 + 0.8 * settled);
let ui = &mut shifted;
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
// the buttons drawn over it keep their own clicks.
// No document open (#1129): every command is greyed out but Help's.
+108
View File
@@ -158,6 +158,78 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 {
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
}
const UNDERLINE_SLIDE_SECS: f64 = 0.2;
const CONTENT_SWITCH_SECS: f64 = 0.16;
pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0;
#[derive(Clone, Debug, PartialEq)]
struct Underline {
tab: String,
from: (f32, f32),
to: (f32, f32),
start: f64,
}
fn underline_at(u: &Underline, now: f64) -> (f32, f32) {
let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32);
(egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e))
}
pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) {
let id = egui::Id::unique("tab_underline");
let prev: Option<Underline> = ui.ctx().data(|d| d.get_temp(id));
let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now };
let next = match prev {
Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap },
Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p },
_ => snap,
};
let (x, width) = underline_at(&next, now);
if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|d| d.insert_temp(id, next));
ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color);
}
#[derive(Clone, Debug, PartialEq)]
struct TabChange {
tab: String,
index: usize,
start: f64,
dir: f32,
}
pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) {
let id = egui::Id::unique("ribbon_tab_change");
let prev: Option<TabChange> = ctx.data(|d| d.get_temp(id));
let next = match prev {
Some(p) if p.tab != tab => TabChange {
tab: tab.to_owned(),
index,
start: now,
dir: if !animate || p.index == index {
0.0
} else if index > p.index {
1.0
} else {
-1.0
},
},
Some(p) => p,
None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 },
};
let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32;
let dir = next.dir;
ctx.data_mut(|d| d.insert_temp(id, next));
if p < 1.0 && dir != 0.0 {
ctx.request_repaint();
(ease_out_cubic(p), dir)
} else {
(1.0, 0.0)
}
}
/// Track contextual tabs between frames and return those still animating out.
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
@@ -346,4 +418,40 @@ mod transition_tests {
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
}
#[test]
fn tab_underline_slides_and_can_reverse_mid_motion() {
let ctx = egui::Context::default();
let draw = |tab: &str, x: f32, now: f64| {
ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
slide_underline(
ui,
tab,
egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)),
egui::Color32::WHITE,
now,
true,
);
})
.drop_without_applying_deltas();
};
draw("Home", 40.0, 1.0);
draw("Insert", 140.0, 2.0);
let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| {
slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true);
});
assert!(!frame.shapes.is_empty());
}
#[test]
fn tab_content_motion_comes_from_the_new_tab_side() {
let ctx = egui::Context::default();
assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0));
let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true);
assert_eq!((p, dir), (0.0, 1.0));
let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true);
assert!(p < 1.0);
assert_eq!(dir, -1.0);
assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0));
}
}
+16 -9
View File
@@ -35,12 +35,19 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
}
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
let fill = if resp.is_pointer_button_down_on() {
bg_if(ui, r, resp, checked, t, true);
}
/// Paints a button background with an optional hover/press response. Disabled ribbon commands
/// pass `false` so their visual state cannot imply that they are interactive.
pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) {
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on);
let fill = if resp.is_pointer_button_down_on() && on {
t.pressed
} else if checked {
t.checked
} else if resp.hovered() {
t.hover
} else if hover > 0.0 {
Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover)
} else {
return;
};
@@ -105,7 +112,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str,
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, id, &label.replace('\n', " "));
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg_if(ui, r, &resp, checked, &t, on);
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
@@ -146,7 +153,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
keytip_badge(ui, r, id, tip);
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg_if(ui, r, &resp, checked, &t, on);
let rtl = crate::rtl::flows(ui);
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
@@ -181,8 +188,8 @@ pub fn split(
keytip_badge(ui, r, id, tip);
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg(ui, ar, &aresp, false, &t);
bg_if(ui, r, &resp, checked, &t, on);
bg_if(ui, ar, &aresp, false, &t, on);
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
@@ -252,7 +259,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
let w = (galley_w + 22.0).max(44.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
bg_if(ui, r, &resp, false, &t, on);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a);
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
let mut y = r.min.y + BIG_LABEL_Y;
@@ -265,7 +272,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
bg_if(ui, r, &resp, false, &t, on);
let rtl = crate::rtl::flows(ui);
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
+47 -1
View File
@@ -1,7 +1,7 @@
#[cfg(test)]
use super::TALL_GROUPS;
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
/// A ribbon group: content, a centred label below and a divider on the right.
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
@@ -26,6 +26,8 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
// Reserve the paint slot before children so the glow stays beneath the controls.
let glow = ui.painter().add(egui::Shape::Noop);
let inner = crate::rtl::vertical(ui, |ui| {
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
@@ -42,6 +44,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
let _ = content;
});
let r = inner.response.rect;
let area = group_area(rtl, start, r);
let over = ui.rect_contains_pointer(area);
// Position is part of identity because duplicate custom group names can appear in tabs.
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
if lit > 0.0 {
ui.painter().set(glow, group_glow(area, t.accent, lit));
}
title_row(
ui,
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
@@ -58,6 +67,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
}
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
/// area starts at the content's own left edge there.
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
let left = if rtl { content.min.x } else { start.x };
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
}
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
/// so there the width is how far the right edge moved.
@@ -65,6 +81,25 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
}
/// A soft accent glow along the bottom of a hovered ribbon group.
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
let soft = (r.width() * 0.3).min(22.0);
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
let mut mesh = egui::epaint::Mesh::default();
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
for (i, x) in xs.iter().enumerate() {
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
}
}
for i in 0..3u32 {
mesh.add_triangle(i, i + 1, i + 5);
mesh.add_triangle(i, i + 5, i + 4);
}
egui::Shape::mesh(mesh)
}
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
let t = Tokens::get(ui.ctx());
@@ -264,4 +299,15 @@ mod tests {
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
assert_eq!(group_width(false, before, after), 160.0);
}
#[test]
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
let start = pos2(f32::NEG_INFINITY, 10.0);
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
let area = group_area(true, start, content);
assert!(area.min.x.is_finite() && area.max.x.is_finite());
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
assert!(!area.intersects(next), "neighbouring groups do not overlap");
}
}
+2 -3
View File
@@ -599,11 +599,10 @@ fn find_link(s: &Session, url: &str) -> Option<(Pos, Pos)> {
}
/// Where the body references note `id`.
pub(crate) fn note_ref_pos(s: &Session, id: u32) -> Option<Pos> {
fn note_ref_pos(s: &Session, id: u32) -> Option<Pos> {
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
// One pass over the objects: `object_at` per offset counted the objects before each one.
let hit = p.objects_with_offsets().find(|(_, o)| matches!(o, InlineObject::NoteRef { id: n, .. } if *n == id)).map(|(off, _)| off);
let hit = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::NoteRef { id: n, .. }) if *n == id));
if let Some(off) = hit {
return Some(Pos::new(StoryRef::Body, path, off));
}
-16
View File
@@ -597,19 +597,3 @@ fn citation_at_document_end_stays_closed() {
let back = wordcraft_engine::io::open_bytes("probe.docx", &bytes).unwrap();
assert_eq!(back.field_ranges(StoryRef::Body).len(), 1);
}
#[test]
fn a_note_reference_is_found_in_one_pass_over_its_paragraph() {
// 20,000 other notes' marks before the one wanted: each was counted again from the start.
let n = 20_000u32;
let mut d = Document::from_text("");
if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) {
p.text = OBJ.to_string().repeat(n as usize);
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }];
p.objects = (1..=n).map(|id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() }).collect();
}
let s = Session::new(d);
let at = crate::bridge::note_ref_pos(&s, n).expect("found");
assert_eq!(at.off, (n as usize - 1) * OBJ.len_utf8());
assert!(crate::bridge::note_ref_pos(&s, n + 1).is_none());
}
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long