//! Artistic and frame text: the story's characters with their font, size, colour, tracking and //! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, //! OpenType features) is left to the importing application. use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect}; use crate::paint::{self, Color, Paint}; use crate::stream::{ObjId, Value}; /// Characters read per text node (stories are split into blocks; this caps hostile ones). const MAX_CHARS: usize = 1 << 20; /// Shared character attributes must not amplify into unbounded font/feature/gradient data. const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; /// Ends a table cell in a table's story (Affinity's break glyph of kind 4). pub const CELL_BREAK: char = '\u{1F}'; pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; let story = s.obj(id, b"StSt")?; let frame = s.obj(id, b"TxtH")?; let artistic = s.is(frame, b"ArFr"); let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; let (runs, align) = story_runs(r, story, world); let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); let anchor_x = match align { Align::Center => (bounds.x0 + bounds.x1) / 2.0, Align::Right => bounds.x1, _ => bounds.x0, }; if !artistic { if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) { r.warn("text frames with several columns (imported as one column)"); } if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) { r.warn("text frames with a curved outline (imported as rectangular frames)"); } } let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) }; Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world, text_transform, }) } /// Rows and columns a table may have. const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; let story = s.obj(id, b"StSt")?; let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?; let bounds = |pos: Option| -> Option> { let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v) }; let columns = bounds(s.obj(tabl, b"CPos"))?; let rows = bounds(s.obj(tabl, b"RPos"))?; let (ncols, nrows) = (columns.len() - 1, rows.len() - 1); let count = ncols.checked_mul(nrows)?; if count > MAX_TABLE_CELLS { r.warn("tables with more than 65536 cells (left out)"); return None; } let Some(left) = r.budget.cells.checked_sub(count) else { r.warn("tables beyond the cells one document may hold (left out)"); return None; }; r.budget.cells = left; let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default(); // Formatting that doesn't match the grid would land on the wrong cells (and merging them, // move text between cells): only text then. let cell_objs = if cell_objs.len() == count { cell_objs } else { r.warn("tables whose cells don't match their grid (cell formatting left out)"); Vec::new() }; let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false }; let mut cells: Vec = (0..count).map(|_| blank()).collect(); for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() { if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) { cell.inset = inset; } cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) { Some(1) => VAlign::Center, Some(2) => VAlign::Bottom, _ => VAlign::Top, }; // A cell merged into the one on its left; the first column has nothing to its left. cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true); } // The story holds the text of every cell that isn't merged away, row by row, each ended by a // cell break. let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect(); let mut owners = owner_list.into_iter(); let mut target = owners.next(); let mut extra = false; let (runs, _) = story_runs(r, story, world); for run in runs { let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { match target.and_then(|i| cells.get_mut(i)) { Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), None => extra = true, } } if parts.peek().is_some() { target = owners.next(); } } } if extra { r.warn("table text beyond its last cell (left out)"); } let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> { let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default(); if objs.len() != expected { r.warn("tables whose lines don't match their grid (lines left out)"); return Vec::new(); } objs.into_iter() .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) { (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false), _ => None, }) .collect() }; let vertical = edges(b"CEdg", (ncols + 1) * nrows); let horizontal = edges(b"REdg", ncols * (nrows + 1)); if let Some(format) = s.obj(tabl, b"TFmt") { let filled = s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN"))); if filled { r.warn("table cell fills (left out)"); } } let text_transform = text_scale(r, id).then(r.type_base); Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform }) } /// A story's character runs, each with its paragraph's format, and the first paragraph's /// alignment. Table cells end with [`CELL_BREAK`]. pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) { let s = r.s; let mut runs = Vec::new(); let mut first_align = None; let mut total = 0usize; let mut placeholders = false; let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); for block in s.objs(story, b"Blok") { let Some(glyphs) = s.obj(block, b"Glyp") else { r.warn("a text block without readable characters (left out)"); continue; }; // Bound collection itself, including placeholders, before allocating the character buffer: // by the story's limit and by what the document's other stories left. let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total)); let mut chars: Vec = match s.str(glyphs, b"Utf8") { Some(t) => t.chars().take(budget + 1).collect(), None => { // Inline objects (page numbers, anchors, cell ends) each take one index before // the segment text. let segments = s.objs(glyphs, b"Mixd"); if segments.is_empty() { r.warn("a text block without readable characters (left out)"); continue; } let mut v = Vec::new(); for seg in segments { for inline in s.objs(seg, b"Glys") { if v.len() > budget { break; } if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) { v.push(CELL_BREAK); } else { v.push('\u{FFFC}'); placeholders = true; } } v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len()))); if v.len() > budget { break; } } v } }; let truncated = chars.len() > budget; if truncated { chars.truncate(budget); if budget < MAX_CHARS - total { r.warn("text beyond the characters one document may hold (truncated)"); } else { r.warn("text longer than a million characters (truncated)"); } } total += chars.len(); // Paragraph formats by end index; a run without an index covers the rest of the block. let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new(); let mut open: Option = None; for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) { Some(end) => paragraphs.push((end, a)), None => match open { None => open = Some(a), Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"), _ => {} }, } } // Sorted by end (they are in order in every file), so both lookups below are binary // searches: a block can have as many paragraphs and runs as characters. paragraphs.sort_by_key(|(end, _)| *end); let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default(); let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); cuts.dedup(); let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { // Split at paragraph ends so every run has one paragraph format. let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default(); let mut a = from; for &c in inside.iter().chain(std::iter::once(&to)) { push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a)); a = c; } }; let mut start = 0usize; for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); continue; }; let end = index.min(chars.len()); if index > chars.len() || end < start { r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); } if end <= start { continue; } push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget); start = end; } if start < chars.len() { push(r, &mut runs, None, start, chars.len(), &mut attribute_budget); } if truncated { break; } } r.budget.chars = r.budget.chars.saturating_sub(total); if placeholders { r.warn("text fields such as page numbers (left out of the text)"); } (runs, first_align.unwrap_or(Align::Left)) } fn characters(chars: &[char], start: usize, end: usize) -> String { chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() } fn push_run( r: &mut Reader, runs: &mut Vec, attrs: Option, text: String, world: Affine, attribute_budget: &mut Option, paragraph: Paragraph, ) { if text.is_empty() { return; } let run = match attrs { Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) }, _ => { // This is a recovery policy, not an assertion about unspecified Affinity attributes. if attrs.is_none() { r.warn("text without character attributes (characters preserved with default attributes)"); } TextRun { text, postscript: String::new(), family: String::new(), weight: 400, italic: false, features: Vec::new(), all_caps: false, underline: false, strikethrough: false, size: 12.0 * r.dpi / 72.0, tracking: 0.0, h_scale: 1.0, leading: None, fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), stroke: None, paragraph, } } }; runs.push(run); } fn slot(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option { match v? { Value::Array(a) => a.get(i).and_then(f), _ => None, } } fn float(v: &Value) -> Option { match v { Value::Float(f) if f.is_finite() => Some(*f), _ => None, } } fn int(v: &Value) -> Option { match v { Value::Int(i) => Some(*i), Value::UInt(u) => i64::try_from(*u).ok(), _ => None, } } fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option) -> TextRun { let s = r.s; let doubles = s.field(attrs, b"Doub"); let ints = s.field(attrs, b"Ints"); let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0); let tracking = slot(doubles, 4, float).unwrap_or(0.0); let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0); let objects = match s.field(attrs, b"Objs") { Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(), _ => Vec::new(), }; let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) { Some(fill) => fill, None => { r.warn("text without readable fill attributes (default black used)"); Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }) } }; // A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data // before keeping it; subsequent runs skip attribute decoding once the budget is exhausted. if let Paint::Gradient(gradient) = &fill { let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::()); if !charge_attributes(r, attribute_budget, bytes) { fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); } } // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes). let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) { (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false), _ => None, }; // Its gradient stops and dashes are owned data too, charged like the fill's. if let Some(st) = &stroke { let stops = match &st.paint { Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::()), _ => 0, }; let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) { stroke = None; } } // Underline (slot 0) and strikethrough (slot 1): 1 is a single line. let mut decoration = |i: usize| match slot(ints, i, int) { Some(1) => true, Some(0) | None => false, Some(_) => { r.warn("text decorations of an unknown kind (left out)"); false } }; let (underline, strikethrough) = (decoration(0), decoration(1)); let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0); // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af let requested = s.obj(attrs, b"DFnt"); let resolved = s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty()); let font = resolved.or(requested); if let Some(resolved) = resolved { let differs = [b"Famy", b"Post"] .iter() .any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default()); if differs { r.warn("text uses a stored fallback font (imported with that font)"); } } if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) { r.warn("font width variants (exact face depends on the installed PostScript font)"); } let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget); let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400); let weight = if (1..=1000).contains(&weight) { weight } else { r.warn("invalid font weight (imported as regular)"); 400 }; TextRun { text, postscript, family, weight, italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), features, all_caps, underline, strikethrough, size, tracking, h_scale, leading, fill, stroke, paragraph: Paragraph::default(), } } fn charge_attributes(r: &mut Reader, budget: &mut Option, bytes: usize) -> bool { let Some(remaining) = *budget else { return false }; match remaining.checked_sub(bytes) { Some(remaining) => { *budget = Some(remaining); true } None => { *budget = None; r.warn("text attribute budget exceeded (excess font names, features and gradients left out)"); false } } } /// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it. const MAX_FONT_NAME: usize = 256; fn font_name(r: &mut Reader, budget: &mut Option, value: &str) -> String { if value.len() > MAX_FONT_NAME { r.warn("font names longer than 256 bytes (the default font is used)"); return String::new(); } metadata_string(r, budget, value) } fn metadata_string(r: &mut Reader, budget: &mut Option, value: &str) -> String { let bytes = value.len().saturating_add(std::mem::size_of::()); if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() } } /// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in /// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other /// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Option) -> (Vec, bool) { let mut features = Vec::new(); let mut all_caps = false; if attribute_budget.is_none() { return (features, all_caps); } let s = r.s; let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) }; for (index, setting) in settings.iter().take(257).enumerate() { if index == 256 { r.warn("more than 256 text feature settings (truncated)"); break; } let Value::Obj(Some(setting)) = setting else { r.warn("unreadable text feature settings (imported with defaults)"); continue; }; let setting = *setting; let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes); let value = r.s.int(setting, b"Valu"); match (tag, value) { (Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1), (Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => { if !charge_attributes(r, attribute_budget, std::mem::size_of::() + if value == 0 { 5 } else { 4 }) { break; } let name = String::from_utf8_lossy(&tag); features.push(if value == 0 { format!("-{name}") } else { name.into_owned() }); } _ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"), } } (features, all_caps) } /// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic /// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading /// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the /// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space /// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing /// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the /// thumbnail of a document whose owner made it earlier. fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph { let doubles = r.s.field(p, b"Doub"); let ints = r.s.field(p, b"Ints"); let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0); let align = match slot(ints, 0, int) { Some(1) => Align::Center, Some(2) => Align::Right, Some(3) => Align::Justify, Some(0) | None => Align::Left, Some(_) => { r.warn("an unknown paragraph alignment (imported as left)"); Align::Left } }; let leading = match slot(ints, 1, int) { Some(2) => Some(at(1)).filter(|l| *l > 0.0), Some(0) | None => None, Some(_) => { r.warn("paragraph leading of an unknown kind (imported as automatic)"); None } }; let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0); Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading } } /// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set. /// Neither the frame's own transform nor its groups' scale its type (checked against the /// thumbnails of tables and frames inside scaled groups). fn text_scale(r: &mut Reader, id: ObjId) -> Affine { match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) { Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]), Some(_) => { r.warn("text frames with an invalid text scale (imported unscaled)"); Affine::IDENTITY } None => Affine::IDENTITY, } }