mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
@@ -1056,7 +1056,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
// Footnotes referenced on this line need room at the bottom of the column too
|
||||
// (a line at the top of a column is set anyway).
|
||||
let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() };
|
||||
let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width();
|
||||
let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width());
|
||||
let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts);
|
||||
let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve };
|
||||
// As in InDesign, a line is set when its baseline fits: its descenders may hang below the frame.
|
||||
@@ -1205,9 +1205,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
keep_violation: false,
|
||||
drop_cap: drop_box,
|
||||
});
|
||||
let placed_notes = !line_notes.is_empty();
|
||||
for k in line_notes {
|
||||
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
|
||||
}
|
||||
// Notes spanning the columns take room from the columns before this one too: re-lay
|
||||
// the frame with that room left.
|
||||
if placed_notes {
|
||||
let r = notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts);
|
||||
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
|
||||
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
|
||||
{
|
||||
rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
pi = j;
|
||||
continue 'paras;
|
||||
}
|
||||
}
|
||||
if !sub_objects.is_empty() {
|
||||
let ft = &mut out.frames[cur.fi];
|
||||
let li = ft.lines.len() - 1;
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
//! First Baseline offset below the top of the footnote area, and each later footnote's first
|
||||
//! baseline sits its first line's leading (plus Space Between Footnotes) below the previous
|
||||
//! footnote's last baseline. Only the last footnote's descent counts toward the area's height.
|
||||
//!
|
||||
//! With Span Footnotes Across Columns the notes of a frame share one area across all its columns,
|
||||
//! composed at the frame's text width: every column of the frame makes room for it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
@@ -71,13 +74,39 @@ pub(crate) struct Notes {
|
||||
/// Composed footnote text by (index, label, width).
|
||||
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
|
||||
notes: Vec<Arc<designcraft_doc::Footnote>>,
|
||||
/// Footnotes span the columns of their frame.
|
||||
span: bool,
|
||||
/// Room a frame's earlier columns had to leave for spanning notes placed in a later column;
|
||||
/// kept across re-lays so the columns before the reference make room on the next pass.
|
||||
floor: HashMap<usize, f64>,
|
||||
}
|
||||
|
||||
impl Notes {
|
||||
pub fn new(doc: &Document, story: &Story) -> Notes {
|
||||
let refs: Vec<usize> = if story.notes.is_empty() { Vec::new() } else { story.text.match_indices(FOOTNOTE_REF).map(|(i, _)| i).collect() };
|
||||
let start = if refs.is_empty() { 1 } else { doc.footnote_start(story.id) };
|
||||
Notes { numbers: vec![start; refs.len()], refs, num: start, key: None, placed: Vec::new(), cache: HashMap::new(), notes: story.notes.clone() }
|
||||
Notes {
|
||||
numbers: vec![start; refs.len()],
|
||||
refs,
|
||||
num: start,
|
||||
key: None,
|
||||
placed: Vec::new(),
|
||||
cache: HashMap::new(),
|
||||
notes: story.notes.clone(),
|
||||
span: doc.footnote_options.span_columns,
|
||||
floor: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a note placed in (fi, col) shares the footnote area of column (`fi`, `ci`).
|
||||
fn same_area(&self, p: &Placed, fi: usize, ci: usize) -> bool {
|
||||
p.fi == fi && (self.span || p.col == ci)
|
||||
}
|
||||
|
||||
/// The footnote area of column `col` of a frame with columns `cols`: the column, or all of
|
||||
/// them when notes span columns.
|
||||
pub fn area(&self, cols: &[Rect], col: usize) -> Option<Rect> {
|
||||
if self.span { cols.iter().copied().reduce(|a, b| a.union(b)) } else { cols.get(col.min(cols.len().saturating_sub(1))).copied() }
|
||||
}
|
||||
|
||||
pub fn active(&self) -> bool {
|
||||
@@ -165,11 +194,27 @@ impl Notes {
|
||||
pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 {
|
||||
let o = &doc.footnote_options;
|
||||
let (space_between, space_before) = (o.space_between, o.space_before);
|
||||
let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| p.fi == fi && p.col == col).map(|p| p.m).collect();
|
||||
let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| self.same_area(p, fi, col)).map(|p| p.m).collect();
|
||||
for &k in extra {
|
||||
ms.push(self.compose(doc, k, w, f, opts).1);
|
||||
}
|
||||
if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before }
|
||||
let r = if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before };
|
||||
if self.span { r.max(self.floor.get(&fi).copied().unwrap_or(0.0)) } else { r }
|
||||
}
|
||||
|
||||
/// After spanning notes were placed in column `col` of frame `fi`: whether a line of an earlier
|
||||
/// column now runs into their area. The area is then remembered for the frame, to re-lay it.
|
||||
pub fn earlier_columns_collide(&mut self, lines: &[crate::Line], fi: usize, col: usize, cols: &[Rect], reserve: f64) -> bool {
|
||||
if !self.span || col == 0 || reserve <= self.floor.get(&fi).copied().unwrap_or(0.0) + 0.01 {
|
||||
return false;
|
||||
}
|
||||
let hit = lines
|
||||
.iter()
|
||||
.any(|l| (l.column as usize) < col && cols.get(l.column as usize).is_some_and(|c| l.baseline + l.descent > c.y1 - reserve + 0.01));
|
||||
if hit {
|
||||
self.floor.insert(fi, reserve);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) {
|
||||
@@ -186,13 +231,13 @@ impl Notes {
|
||||
return;
|
||||
}
|
||||
let o = &doc.footnote_options;
|
||||
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, p.col)).collect();
|
||||
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, if self.span { 0 } else { p.col })).collect();
|
||||
groups.dedup();
|
||||
groups.sort_unstable();
|
||||
groups.dedup();
|
||||
for (fi, ci) in groups {
|
||||
let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue };
|
||||
let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect();
|
||||
let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).filter(|c| ci < c.len()).and_then(|c| self.area(c, ci))) else { continue };
|
||||
let here: Vec<&Placed> = self.placed.iter().filter(|p| self.same_area(p, fi, ci)).collect();
|
||||
let total = stack_height(here.iter().map(|p| &p.m), o.space_between);
|
||||
let top = col.y1 - total;
|
||||
let ft = &mut out.frames[fi];
|
||||
@@ -220,7 +265,7 @@ impl Notes {
|
||||
ft.notes.push(PlacedNote {
|
||||
id: note.id,
|
||||
index: p.k,
|
||||
column: ci as u32,
|
||||
column: p.col as u32,
|
||||
label: o.label(self.numbers[p.k], true),
|
||||
origin: Point::new(col.x0, y),
|
||||
rect: Rect::new(col.x0, y0, col.x1, y1),
|
||||
|
||||
@@ -1372,6 +1372,44 @@ fn footnote_line_at_column_top_still_sets() {
|
||||
assert_eq!(cs.frames[0].notes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_span_columns_across_the_frame_bottom() {
|
||||
// Span Footnotes Across Columns: the note area runs under every column of the frame and each
|
||||
// column's text ends above it, also the columns before the one holding the reference.
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let note = "A long footnote, long enough to wrap if it is set one column wide rather than across the frame.";
|
||||
let compose = |at: usize, span: bool| {
|
||||
let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 436.0, 236.0), ParaAttrs::default());
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
d.footnote_options.span_columns = span;
|
||||
d.story_mut(sid).unwrap().insert_note(at, note, ParaFormat::default());
|
||||
let space_before = d.footnote_options.space_before;
|
||||
(compose_story(&d, sid, &ComposeOptions::default()), space_before)
|
||||
};
|
||||
let plain = compose(10, false).0;
|
||||
// A reference halfway down the second column.
|
||||
let in_col1 = plain.frames[0].lines.iter().filter(|l| l.column == 1).nth(5).unwrap().range.start + 5;
|
||||
for (at, ref_col) in [(10, 0), (in_col1, 1)] {
|
||||
let (cs, space_before) = compose(at, true);
|
||||
let ft = &cs.frames[0];
|
||||
let (c0, c1) = (ft.columns[0], ft.columns[1]);
|
||||
assert_eq!(ft.notes.len(), 1);
|
||||
let n = &ft.notes[0];
|
||||
assert_eq!(n.column, ref_col, "ref at {at}");
|
||||
assert!((n.rect.x0 - c0.x0).abs() < 1e-6 && (n.rect.x1 - c1.x1).abs() < 1e-6, "ref at {at}: note {:?} spans the frame", n.rect);
|
||||
assert!((n.rect.y1 - c0.y1).abs() < 1e-6, "ref at {at}: note ends at the frame bottom");
|
||||
for col in 0..2 {
|
||||
let last = ft.lines.iter().rfind(|l| l.column == col).unwrap();
|
||||
assert!(last.baseline + last.descent <= n.rect.y0 - space_before + 0.01, "ref at {at}: column {col} text clears the note");
|
||||
}
|
||||
assert!(n.text.frames[0].lines.iter().all(|l| l.x1 - l.x0 > c0.width() + 1.0), "ref at {at}: note set at the frame width");
|
||||
}
|
||||
// Off: the note stays in the column holding its reference.
|
||||
let n = &compose(in_col1, false).0.frames[0].notes[0];
|
||||
assert_eq!(n.column, 1);
|
||||
assert!((n.rect.x0 - plain.frames[0].columns[1].x0).abs() < 1e-6 && (n.rect.x1 - plain.frames[0].columns[1].x1).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hj_severity_shades() {
|
||||
use crate::hj_severity;
|
||||
|
||||
@@ -277,7 +277,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
|
||||
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
|
||||
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options.
|
||||
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
|
||||
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
|
||||
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
|
||||
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
|
||||
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
|
||||
|
||||
Reference in New Issue
Block a user