Skip to main content

rdocx_layout/
notes.rs

1//! Footnote and endnote content, laid out once before pagination.
2//!
3//! Notes used to be laid out inside the post-pagination pass that drew them,
4//! which meant pagination could not know how much room they would need and
5//! drew body text straight over them. Laying them out here, ahead of
6//! pagination, lets the paginator reserve exactly the height it will later
7//! draw. Reserve and render read the same lines, so they cannot disagree.
8//!
9//! The marker is shaped here too. The paginator only holds `&FontManager` and
10//! shaping needs `&mut`, so a note that arrives pre-shaped is a note the
11//! paginator can place without touching a font.
12
13use std::collections::HashMap;
14
15use rdocx_oxml::styles::CT_Styles;
16
17use crate::WordStory;
18use crate::block::ParagraphBlock;
19use crate::engine::{SourceRegistry, layout_paragraph_with_source_and_direction};
20use crate::input::{LayoutInput, MediaRegistry};
21use crate::style_resolver::NumberingState;
22use oxml_layout::{
23    Color, Diagnostic, FontManager, LayoutLine, NoteRef, NoteStream, Result, TextDirection,
24    TextSegment,
25};
26
27/// Point size notes are set at.
28const NOTE_FONT_SIZE: f64 = 8.0;
29/// Horizontal space reserved for the marker, to the left of note text.
30///
31/// Notes are both line-broken and drawn against this, so the two agree.
32pub const NOTE_INDENT: f64 = 12.0;
33/// Vertical gap between the separator rule and the first note line.
34pub const NOTE_SEPARATOR_OFFSET: f64 = 6.0;
35/// Width of the rule above a note that starts on its own page, as a fraction
36/// of the content width.
37pub const SEPARATOR_WIDTH_FRACTION: f64 = 0.33;
38
39/// One note, laid out and ready to place.
40#[derive(Debug, Clone)]
41pub struct NoteLayout {
42    /// The pre-shaped superscript number drawn at the start of the note.
43    pub marker: TextSegment,
44    /// How far above the baseline the marker sits.
45    pub marker_rise: f64,
46    /// The note's lines, flattened across its paragraphs.
47    pub lines: Vec<LayoutLine>,
48    /// Line ranges belonging to paragraphs with a visible tracked revision.
49    pub revision_ranges: Vec<std::ops::Range<usize>>,
50}
51
52impl NoteLayout {
53    /// Height of a range of this note's lines.
54    pub fn height_of(&self, first: usize, count: usize) -> f64 {
55        self.lines
56            .iter()
57            .skip(first)
58            .take(count)
59            .map(|line| line.height)
60            .sum()
61    }
62
63    /// Height of every line from `first` onward.
64    pub fn height_from(&self, first: usize) -> f64 {
65        self.height_of(first, self.lines.len())
66    }
67
68    /// Total height of every line.
69    pub fn height(&self) -> f64 {
70        self.height_from(0)
71    }
72}
73
74/// A note, and the content width it was broken to.
75///
76/// The width is held as raw bits because `f64` is not `Hash`. Both the key and
77/// every lookup come from `PageGeometry::content_width()` over the same
78/// `sectPr`, so this is exact equality on a value that was computed the same
79/// way twice, not a comparison that needs a tolerance.
80type NoteKey = (NoteRef, u64);
81
82/// Every note the document defines, laid out once per distinct width.
83#[derive(Debug, Clone, Default)]
84pub struct NoteRegistry {
85    notes: HashMap<NoteKey, NoteEntry>,
86    continuation_separator: bool,
87}
88
89#[derive(Debug, Clone)]
90struct NoteEntry {
91    layout: NoteLayout,
92    paragraphs: Vec<NoteRenderParagraph>,
93}
94
95#[derive(Debug, Clone)]
96pub(crate) struct NoteRenderParagraph {
97    pub block: ParagraphBlock,
98    pub direction: TextDirection,
99    pub lines: std::ops::Range<usize>,
100}
101
102impl NoteRegistry {
103    /// Lay out every note in the footnote and endnote streams, once for each
104    /// distinct content width the document paginates at.
105    ///
106    /// A note is broken at `content_width - NOTE_INDENT`, because that is where
107    /// it is drawn, and the width that matters is the one belonging to the
108    /// section carrying the reference rather than the document's last section.
109    /// `content_widths` may repeat, and a repeat costs nothing: the common
110    /// document, whose sections share a page size, lays each note out once.
111    pub(crate) fn build(
112        input: &LayoutInput,
113        styles: &CT_Styles,
114        media: &MediaRegistry,
115        fm: &mut FontManager,
116        num_state: &mut NumberingState,
117        content_widths: &[f64],
118        diagnostics: &mut Vec<Diagnostic>,
119        sources: Option<&SourceRegistry>,
120    ) -> Result<Self> {
121        let mut notes = HashMap::new();
122        let mut continuation_separator = false;
123
124        // Each stream is keyed separately, so a document numbering a footnote
125        // and an endnote alike keeps both.
126        for (kind, stream) in [
127            (NoteStream::Footnote, input.footnotes.as_ref()),
128            (NoteStream::Endnote, input.endnotes.as_ref()),
129        ]
130        .into_iter()
131        .filter_map(|(kind, stream)| stream.map(|stream| (kind, stream)))
132        {
133            if stream.has_continuation_separator() {
134                continuation_separator = true;
135            }
136
137            for note in &stream.footnotes {
138                // `get_by_id` is the authority on what counts as a real note,
139                // so separators never reach the registry.
140                if stream.get_by_id(note.id).is_none() {
141                    continue;
142                }
143                let note_ref = NoteRef {
144                    stream: kind,
145                    id: note.id,
146                };
147
148                // Laying the same note out again must not consume its list
149                // numbers again, so every width after the first starts from the
150                // counters the first one started from. Numbering does not
151                // depend on the width, so the state left behind is the state a
152                // single layout would have left.
153                let counters_before = num_state.clone();
154                let mut laid_out = false;
155
156                for &content_width in content_widths {
157                    let key = (note_ref, content_width.to_bits());
158                    if notes.contains_key(&key) {
159                        continue;
160                    }
161                    if laid_out {
162                        *num_state = counters_before.clone();
163                    }
164                    laid_out = true;
165                    let note_width = (content_width - NOTE_INDENT).max(1.0);
166
167                    let mut lines = Vec::new();
168                    let mut revision_ranges = Vec::new();
169                    let mut render_paragraphs = Vec::new();
170                    let story = match kind {
171                        NoteStream::Footnote => WordStory::Footnote { id: note.id },
172                        NoteStream::Endnote => WordStory::Endnote { id: note.id },
173                    };
174                    for (paragraph_index, paragraph) in note.paragraphs.iter().enumerate() {
175                        let source =
176                            sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
177                        let (block, direction) = layout_paragraph_with_source_and_direction(
178                            paragraph,
179                            note_width,
180                            styles,
181                            input,
182                            media,
183                            fm,
184                            num_state,
185                            diagnostics,
186                            source,
187                        )?;
188                        let first = lines.len();
189                        if block.has_visible_revision && !block.lines.is_empty() {
190                            revision_ranges.push(first..first + block.lines.len());
191                        }
192                        lines.extend(block.lines.iter().cloned());
193                        let last = lines.len();
194                        render_paragraphs.push(NoteRenderParagraph {
195                            block,
196                            direction,
197                            lines: first..last,
198                        });
199                    }
200
201                    let Some(marker) = shape_marker(note.id, fm)? else {
202                        continue;
203                    };
204
205                    notes.insert(
206                        key,
207                        NoteEntry {
208                            layout: NoteLayout {
209                                marker,
210                                marker_rise: NOTE_FONT_SIZE * 0.33,
211                                lines,
212                                revision_ranges,
213                            },
214                            paragraphs: render_paragraphs,
215                        },
216                    );
217                }
218            }
219        }
220
221        Ok(NoteRegistry {
222            notes,
223            continuation_separator,
224        })
225    }
226
227    /// The note as broken for a section of this content width.
228    pub fn get(&self, note: NoteRef, content_width: f64) -> Option<&NoteLayout> {
229        self.notes
230            .get(&(note, content_width.to_bits()))
231            .map(|entry| &entry.layout)
232    }
233
234    pub(crate) fn get_render(
235        &self,
236        note: NoteRef,
237        content_width: f64,
238    ) -> Option<(&NoteLayout, &[NoteRenderParagraph])> {
239        self.notes
240            .get(&(note, content_width.to_bits()))
241            .map(|entry| (&entry.layout, entry.paragraphs.as_slice()))
242    }
243
244    /// Whether either stream defined the rule drawn above a carried note.
245    pub fn has_continuation_separator(&self) -> bool {
246        self.continuation_separator
247    }
248}
249
250/// Shape a note's number as the superscript marker drawn beside it.
251fn shape_marker(id: i32, fm: &mut FontManager) -> Result<Option<TextSegment>> {
252    let text = id.to_string();
253    let size = NOTE_FONT_SIZE * 0.58;
254
255    let Ok(font_id) = fm.resolve_font(Some("serif"), false, false) else {
256        return Ok(None);
257    };
258    let Ok(shaped) = fm.shape_text(font_id, &text, size) else {
259        return Ok(None);
260    };
261    let metrics = fm.metrics(font_id, size)?;
262
263    Ok(Some(TextSegment {
264        text,
265        direction: oxml_layout::TextDirection::Auto,
266        source: None,
267        font_id,
268        font_size: size,
269        glyph_ids: shaped.glyph_ids,
270        advances: shaped.advances,
271        width: shaped.width,
272        ascent: metrics.ascent,
273        descent: metrics.descent,
274        line_gap: 0.0,
275        color: Color::BLACK,
276        bold: false,
277        italic: false,
278        underline: None,
279        strike: false,
280        dstrike: false,
281        highlight: None,
282        baseline_offset: 0.0,
283        hyperlink_url: None,
284        field_kind: None,
285        note: None,
286    }))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
293    use rdocx_oxml::text::CT_P;
294
295    /// One footnote, numbered 1, whose text is long enough to wrap at any of
296    /// the widths under test.
297    fn input_with_one_note() -> LayoutInput {
298        let mut note = CT_P::new();
299        note.add_run(
300            "A note long enough that the measure it is broken to decides how \
301             many lines it occupies rather than leaving it on a single line.",
302        );
303
304        LayoutInput {
305            revision_view: crate::input::RevisionView::Accepted,
306            automatic_hyphenation: false,
307            math_properties: None,
308            document: rdocx_oxml::document::CT_Document::new(),
309            styles: CT_Styles::new_default(),
310            numbering: None,
311            headers: HashMap::new(),
312            footers: HashMap::new(),
313            images: HashMap::new(),
314            charts: HashMap::new(),
315            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
316            chart_color_map: oxml_drawing::color::ColorMap::default(),
317            core_properties: None,
318            hyperlink_urls: HashMap::new(),
319            footnotes: Some(CT_Footnotes {
320                footnotes: vec![CT_Footnote {
321                    id: 1,
322                    note_type: NoteType::Normal,
323                    paragraphs: vec![note],
324                }],
325            }),
326            endnotes: None,
327            theme: None,
328            fonts: Vec::new(),
329        }
330    }
331
332    fn build_at(widths: &[f64]) -> NoteRegistry {
333        let input = input_with_one_note();
334        let media = MediaRegistry::new(&HashMap::new());
335        let mut fm = FontManager::new();
336        let mut num_state = NumberingState::new();
337        let mut diagnostics = Vec::new();
338        NoteRegistry::build(
339            &input,
340            &input.styles,
341            &media,
342            &mut fm,
343            &mut num_state,
344            widths,
345            &mut diagnostics,
346            None,
347        )
348        .expect("the registry builds")
349    }
350
351    const NOTE_ONE: NoteRef = NoteRef {
352        stream: NoteStream::Footnote,
353        id: 1,
354    };
355
356    #[test]
357    fn the_registry_lays_a_note_out_once_per_distinct_width() {
358        let registry = build_at(&[468.0, 1044.0]);
359
360        let narrow = registry.get(NOTE_ONE, 468.0).expect("narrow is registered");
361        let wide = registry.get(NOTE_ONE, 1044.0).expect("wide is registered");
362
363        assert!(
364            wide.lines.len() < narrow.lines.len(),
365            "one layout was reused for both widths, {} lines against {}",
366            wide.lines.len(),
367            narrow.lines.len()
368        );
369    }
370
371    #[test]
372    fn a_repeated_width_is_registered_once_and_still_found() {
373        let repeated = build_at(&[468.0, 468.0]);
374        let once = build_at(&[468.0]);
375
376        let from_repeated = repeated.get(NOTE_ONE, 468.0).expect("still registered");
377        let from_once = once.get(NOTE_ONE, 468.0).expect("registered");
378        assert_eq!(from_repeated.lines.len(), from_once.lines.len());
379    }
380
381    #[test]
382    fn an_unregistered_width_has_no_layout() {
383        // The engine registers every width it paginates, so a miss means the
384        // caller and the builder disagree, and silently drawing the wrong
385        // measure would be worse than drawing nothing.
386        let registry = build_at(&[468.0]);
387        assert!(registry.get(NOTE_ONE, 1044.0).is_none());
388    }
389}