Files
vectorcraft/crates/affinity/src/text.rs
T
dwetscherandClaude Opus 5.5 b3ccdf01a7 AF.5: nested overlays innermost first; embedded master pages; text outline budget
- Overlays bake child-first: a group's overlay now lies over its children's
  (they used to be applied after it and win). Each one pays out of the
  import's overlay work budget.
- Master page instances inside embedded documents are left out with a warning:
  their indices count the embedded document's masters, which aren't kept, and
  would show the outer document's.
- A text outline's gradient stops and dashes are charged to the text attribute
  budget, like the fill's.

Addresses CodeRabbit's second review on #3548.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 18:24:57 +07:00

554 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Text> {
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<Table> {
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<ObjId>| -> Option<Vec<f64>> {
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
(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<TableCell> = (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<usize> = (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<Option<crate::paint::Stroke>> {
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<TextRun>, 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<char> = 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<Paragraph> = 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<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// 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<TextRun>,
attrs: Option<ObjId>,
text: String,
world: Affine,
attribute_budget: &mut Option<usize>,
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<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
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<usize>) -> 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::<paint::Stop>());
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::<paint::Stop>()),
_ => 0,
};
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
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<usize>, 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<usize>, 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<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
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<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, 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::<String>() + 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,
}
}