mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
- doc: INDEX_MARK with Story::index_refs kept in sync (topics 1–4 levels, sort keys, current page / to end of story / next n paragraphs / suppressed / See / See also); index builder with section headings, nested or run-in entries, merged page ranges; Document::index for updates - engine: index.addReference (topic from the selection), index.list, index.generate, index.update with Index Title / Section Head / Level 1–4 styles (+ tests) - IDML: PageReference markers and the Index topic tree with See / See also cross-references, both ways (verified in InDesign 2026) - Layout › Index menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.1 KiB
Rust
152 lines
6.1 KiB
Rust
//! Text variable resolution at composition time, including running headers (which depend on
|
|
//! where paragraphs of a style land in the composed document).
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use designcraft_doc::vars::{Use, VarKind, var_index};
|
|
use designcraft_doc::{Document, SpreadRef, Story, StoryId};
|
|
|
|
use crate::ComposedStory;
|
|
|
|
/// Does the story contain any text variable instance?
|
|
pub fn has_vars(st: &Story) -> bool {
|
|
st.text.chars().any(|c| var_index(c).is_some())
|
|
}
|
|
|
|
/// Does the story contain a running-header variable instance?
|
|
pub fn has_running(doc: &Document, st: &Story) -> bool {
|
|
st.text.chars().filter_map(var_index).any(|i| matches!(doc.text_variables.get(i).map(|v| &v.kind), Some(VarKind::RunningHeader { .. })))
|
|
}
|
|
|
|
/// Absolute page showing story byte `pos` of a composed story.
|
|
pub fn page_of(doc: &Document, cs: &ComposedStory, pos: usize) -> Option<usize> {
|
|
let (fi, ..) = crate::caret(cs, pos)?;
|
|
// Overset text has no page.
|
|
let ft = cs.frames.get(fi)?;
|
|
if pos > ft.range.end && cs.overset_at.is_some_and(|o| pos >= o) {
|
|
return None;
|
|
}
|
|
place(doc, cs, pos).map(|p| p.0)
|
|
}
|
|
|
|
/// Per (style, is character style): page → (first text, last text) on that page.
|
|
#[derive(Debug, Default)]
|
|
pub struct RunningIndex {
|
|
map: HashMap<(String, bool), BTreeMap<usize, (String, String)>>,
|
|
}
|
|
|
|
fn clean(s: &str) -> String {
|
|
let t: String =
|
|
s.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\t' || c == '\u{2028}' { ' ' } else { c }).collect();
|
|
t.trim().to_string()
|
|
}
|
|
|
|
/// (absolute page, y, x) of story byte `pos` in a composed story.
|
|
pub(crate) fn place(doc: &Document, cs: &ComposedStory, pos: usize) -> Option<(usize, i64, i64)> {
|
|
let (fi, x, baseline, ..) = crate::caret(cs, pos)?;
|
|
let ft = cs.frames.get(fi)?;
|
|
let loc = doc.find(ft.frame)?;
|
|
let SpreadRef::Doc(si) = loc.spread else { return None };
|
|
let b = doc.item(ft.frame)?.bounds();
|
|
let pi = doc.spreads.get(si)?.page_at_x(b.center().x).unwrap_or(0);
|
|
Some((doc.first_page_of_spread(si) + pi, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64))
|
|
}
|
|
|
|
impl RunningIndex {
|
|
/// Index the paragraphs / runs that running-header variables refer to. `get` composes a story
|
|
/// (normally through the cache); stories that themselves contain running headers are skipped.
|
|
pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc<ComposedStory>) -> RunningIndex {
|
|
let wanted: Vec<(String, bool)> = doc
|
|
.text_variables
|
|
.iter()
|
|
.filter_map(|v| match &v.kind {
|
|
VarKind::RunningHeader { style, character, .. } => Some((style.clone(), *character)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let mut hits: HashMap<(String, bool), Vec<((usize, i64, i64), String)>> = HashMap::new();
|
|
if wanted.is_empty() {
|
|
return RunningIndex::default();
|
|
}
|
|
for (sid, st) in &doc.stories {
|
|
if has_running(doc, st) {
|
|
continue;
|
|
}
|
|
let para_hit = st.paras.iter().any(|f| wanted.iter().any(|(s, c)| !c && *s == f.style));
|
|
let char_hit = wanted.iter().any(|(_, c)| *c) && st.runs().any(|(_, f)| wanted.iter().any(|(s, c)| *c && *s == f.style));
|
|
if !para_hit && !char_hit {
|
|
continue;
|
|
}
|
|
let cs = get(*sid);
|
|
if para_hit {
|
|
for (pi, r) in st.para_ranges().into_iter().enumerate() {
|
|
let key = (st.paras[pi].style.clone(), false);
|
|
if !wanted.contains(&key) {
|
|
continue;
|
|
}
|
|
let text = clean(&st.text[r.clone()]);
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(at) = place(doc, &cs, r.start) {
|
|
hits.entry(key).or_default().push((at, text));
|
|
}
|
|
}
|
|
}
|
|
if char_hit {
|
|
for (r, f) in st.runs() {
|
|
let key = (f.style.clone(), true);
|
|
if !wanted.contains(&key) {
|
|
continue;
|
|
}
|
|
let text = clean(&st.text[r.clone()]);
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(at) = place(doc, &cs, r.start) {
|
|
hits.entry(key).or_default().push((at, text));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut map = HashMap::new();
|
|
for (key, mut v) in hits {
|
|
v.sort_by_key(|(at, _)| *at);
|
|
let mut pages: BTreeMap<usize, (String, String)> = BTreeMap::new();
|
|
for ((page, ..), text) in v {
|
|
pages.entry(page).and_modify(|e| e.1 = text.clone()).or_insert((text.clone(), text));
|
|
}
|
|
map.insert(key, pages);
|
|
}
|
|
RunningIndex { map }
|
|
}
|
|
|
|
/// The running header for `page`: the first/last on the page, else the last before it.
|
|
pub fn value(&self, style: &str, character: bool, use_: Use, page: usize) -> String {
|
|
let Some(pages) = self.map.get(&(style.to_string(), character)) else { return String::new() };
|
|
if let Some((first, last)) = pages.get(&page) {
|
|
return if use_ == Use::FirstOnPage { first.clone() } else { last.clone() };
|
|
}
|
|
pages.range(..page).next_back().map(|(_, (_, last))| last.clone()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// Values of every variable on `page`.
|
|
pub fn values(doc: &Document, page: Option<usize>, running: Option<&RunningIndex>) -> Vec<String> {
|
|
doc.text_variables
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| match &v.kind {
|
|
VarKind::RunningHeader { style, use_, character } => {
|
|
let body = match (running, page) {
|
|
(Some(r), Some(p)) => r.value(style, *character, *use_, p),
|
|
_ => String::new(),
|
|
};
|
|
format!("{}{}{}", v.before, body, v.after)
|
|
}
|
|
_ => doc.variable_value(i, page).unwrap_or_default(),
|
|
})
|
|
.collect()
|
|
}
|