vectorcraft/crates/affinity/src/text.rs

323 lines
14 KiB
Rust

//! 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, Point, Reader, Text, TextRun, 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;
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 mut runs = Vec::new();
let mut 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.
let budget = MAX_CHARS - 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) 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 {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
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;
}
let text = characters(&chars, start, end);
start = end;
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
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)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
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>) {
if text.is_empty() {
return;
}
let run = match attrs {
Some(attrs) if attribute_budget.is_some() => 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,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
}
}
};
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 });
}
}
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
{
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
// 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 = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(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,
size,
tracking,
leading,
fill,
}
}
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
}
}
}
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)
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("paragraph indents or spacing (imported with defaults)");
}
match slot(r.s.field(p, b"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
}
}
}