mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Document symbols placed inline in a run of type (beyond Illustrator; like InDesign inline anchored objects): TextRun.inline holds the symbol, scale and shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on the cap height. text.insertInline and Type > Insert Inline Symbol; editing keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create Outlines turns it into a symbol instance.
231 lines
11 KiB
Rust
231 lines
11 KiB
Rust
//! Real text: type exported as selectable, searchable text in embedded subset fonts with a
|
|
//! ToUnicode map, where the outlines would be; fonts whose licence forbids it stay outlines.
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
|
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextKind, TextObject, TextRun};
|
|
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
|
|
use vectorcraft_text::{FALLBACK_FAMILY, FontDb};
|
|
|
|
use crate::*;
|
|
|
|
fn style(size: f64) -> CharStyle {
|
|
CharStyle { size, fill: Paint::solid(Color::BLACK), ..Default::default() }
|
|
}
|
|
|
|
/// A document holding `texts`.
|
|
fn doc(texts: Vec<TextObject>) -> Document {
|
|
let mut d = Document::new(400.0, 300.0);
|
|
let l = d.layers[0].id;
|
|
for (i, t) in texts.into_iter().enumerate() {
|
|
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
|
d.insert(Some(l), i, n).unwrap();
|
|
}
|
|
d
|
|
}
|
|
|
|
/// The export of `d` with real text (or outlines), content streams left readable.
|
|
fn pdf(d: &Document, outline_text: bool) -> ExportReport {
|
|
let settings: PdfSettings =
|
|
serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": outline_text}})).unwrap();
|
|
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
|
|
}
|
|
|
|
fn import_as(bytes: &[u8], text_as: TextAs) -> Document {
|
|
import_with_report(bytes, &ImportOptions { text_as, ..Default::default() }).unwrap().document
|
|
}
|
|
|
|
/// The text of the type objects of `d`.
|
|
fn texts(d: &Document) -> Vec<String> {
|
|
let mut out = vec![];
|
|
d.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
out.push(t.plain_text());
|
|
}
|
|
});
|
|
out
|
|
}
|
|
|
|
/// The bounds of the paths of `d` (the outlines type imports as), all together.
|
|
fn ink(d: &Document) -> Rect {
|
|
let mut b: Option<Rect> = None;
|
|
d.walk(|n| {
|
|
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
|
&& let Some(r) = n.geometric_bounds()
|
|
{
|
|
b = Some(b.map_or(r, |b| b.union(r)));
|
|
}
|
|
});
|
|
b.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn type_is_written_as_text_in_embedded_subset_fonts() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Hello World", style(24.0))]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
let text = String::from_utf8_lossy(&r.bytes);
|
|
assert!(text.contains("/FontFile2") || text.contains("/FontFile3"), "an embedded font program");
|
|
assert!(text.contains("/ToUnicode") && text.contains("/Type0"));
|
|
// A subset: the font's name carries the subset tag.
|
|
assert!(text.contains(&format!("+{}", FALLBACK_FAMILY.replace(' ', ""))), "a subset");
|
|
assert!(text.contains(" Tf") && text.contains("TJ"));
|
|
// Selectable and searchable: reopened, it is type again, spaces included.
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Hello World"]);
|
|
// Outlined text has no font.
|
|
assert!(!String::from_utf8_lossy(&pdf(&d, true).bytes).contains("/Font"));
|
|
}
|
|
|
|
/// A curve type runs along.
|
|
fn arc() -> PathData {
|
|
let mut p = BezPath::new();
|
|
p.move_to((20.0, 250.0));
|
|
p.curve_to((120.0, 150.0), (260.0, 150.0), (380.0, 250.0));
|
|
PathData::from_bezpath(&p)
|
|
}
|
|
|
|
#[test]
|
|
fn real_text_lands_within_half_a_point_of_the_outlines() {
|
|
let plain = TextObject::point(Point::new(20.0, 60.0), "Hg fly", style(36.0));
|
|
let mut turned = TextObject::point(Point::new(60.0, 160.0), "Wave AV", CharStyle { h_scale: 80.0, tracking: 120.0, ..style(30.0) });
|
|
turned.xf *= Affine::rotate(-0.5) * Affine::skew(0.2, 0.0);
|
|
let mut mixed = TextObject::point(Point::new(200.0, 60.0), "", style(20.0));
|
|
mixed.runs = vec![
|
|
TextRun { text: "x".into(), style: style(20.0), inline: None },
|
|
TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) }, inline: None },
|
|
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) }, inline: None },
|
|
];
|
|
let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0));
|
|
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
|
|
on_path.xf = Affine::IDENTITY;
|
|
for t in [plain, turned, mixed, on_path] {
|
|
let what = t.plain_text();
|
|
let d = doc(vec![t]);
|
|
let real = import_as(&pdf(&d, false).bytes, TextAs::Outlines);
|
|
let outlined = import_as(&pdf(&d, true).bytes, TextAs::Outlines);
|
|
let (a, b) = (ink(&real), ink(&outlined));
|
|
let off = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
|
assert!(off < 0.5, "{what}: {a:?} vs {b:?}");
|
|
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat().replace(' ', ""), what.replace(' ', ""), "{what}");
|
|
}
|
|
}
|
|
|
|
/// A named instance of a variable font is real text in that instance: its embedded subset draws
|
|
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
|
|
#[test]
|
|
fn variable_font_instances_are_real_text_in_their_own_glyphs() {
|
|
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
|
FontDb::global().add_font(variable_font().unwrap());
|
|
let mut widths = vec![];
|
|
for s in ["Regular", "Bold"] {
|
|
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings);
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text");
|
|
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
|
let off =
|
|
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
|
assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}");
|
|
widths.push(real.width());
|
|
}
|
|
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_on_real_text_stay_where_they_were() {
|
|
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
|
let mut t = TextObject::point(Point::new(30.0, 80.0), "Gradient", CharStyle { fill, ..style(40.0) });
|
|
t.xf *= Affine::rotate(0.3);
|
|
let d = doc(vec![t]);
|
|
let geom = |outline: bool| {
|
|
let back = import_as(&pdf(&d, outline).bytes, TextAs::Outlines);
|
|
let mut g = None;
|
|
back.walk(|n| {
|
|
if let Paint::Gradient(p) = n.appearance.fill_paint() {
|
|
g = g.or(p.geom);
|
|
}
|
|
});
|
|
g.unwrap()
|
|
};
|
|
let (real, outlined) = (geom(false), geom(true));
|
|
assert!((real.start - outlined.start).hypot() < 0.1 && (real.end - outlined.end).hypot() < 0.1, "{real:?} vs {outlined:?}");
|
|
}
|
|
|
|
/// The bundled fallback face as another family `family` (13 characters, like the bundled name),
|
|
/// whose licence flags (OS/2 `fsType`) are `fs`.
|
|
fn licensed(family: &str, fs: u16) -> String {
|
|
let face = FontDb::global().face(FALLBACK_FAMILY, "Regular").unwrap();
|
|
let mut bytes = face.file_data().to_vec();
|
|
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
|
for (from, to) in [(utf16(FALLBACK_FAMILY), utf16(family)), (FALLBACK_FAMILY.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
|
assert_eq!(from.len(), to.len());
|
|
let mut at = 0;
|
|
while let Some(i) = bytes[at..].windows(from.len()).position(|w| w == from) {
|
|
bytes[at + i..at + i + from.len()].copy_from_slice(&to);
|
|
at += i + from.len();
|
|
}
|
|
}
|
|
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
|
let os2 = (0..tables).map(|i| 12 + 16 * i).find(|r| &bytes[*r..*r + 4] == b"OS/2").unwrap();
|
|
let off = u32::from_be_bytes(bytes[os2 + 8..os2 + 12].try_into().unwrap()) as usize;
|
|
bytes[off + 8..off + 10].copy_from_slice(&fs.to_be_bytes());
|
|
FontDb::global().add_font(bytes);
|
|
assert!(FontDb::global().has_family(family));
|
|
family.into()
|
|
}
|
|
|
|
#[test]
|
|
fn fonts_whose_licence_forbids_embedding_fall_back_to_outlines() {
|
|
let locked = licensed("Locked Sans 3", 0x0002);
|
|
let whole = licensed("Entire Sans 3", 0x0100);
|
|
let d = doc(vec![
|
|
TextObject::point(Point::new(20.0, 40.0), "Open", style(24.0)),
|
|
TextObject::point(Point::new(20.0, 100.0), "Shut", CharStyle { font_family: locked.clone(), ..style(24.0) }),
|
|
TextObject::point(Point::new(20.0, 160.0), "Full", CharStyle { font_family: whole.clone(), ..style(24.0) }),
|
|
]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.iter().any(|w| w.contains(&locked) && w.contains("doesn't allow embedding")), "{:?}", r.warnings);
|
|
assert!(r.warnings.iter().any(|w| w.contains(&whole) && w.contains("whole")), "{:?}", r.warnings);
|
|
// Only the open font is text; the others are drawn as their outlines.
|
|
let back = import_as(&r.bytes, TextAs::Text);
|
|
assert_eq!(texts(&back), ["Open"]);
|
|
let mut paths = 0;
|
|
back.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
|
|
assert!(paths >= 2, "{paths} outlined words");
|
|
}
|
|
|
|
#[test]
|
|
fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]);
|
|
let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap();
|
|
let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
|
assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings);
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]);
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_a_takes_real_text() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Archive", style(24.0))]);
|
|
let settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}})).unwrap();
|
|
let bytes = export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap();
|
|
assert_eq!(texts(&import_as(&bytes, TextAs::Text)), ["Archive"]);
|
|
}
|
|
|
|
/// A named instance comes back as live type in that style (not the default instance's).
|
|
#[test]
|
|
fn a_variable_font_instance_comes_back_in_its_own_style() {
|
|
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
|
FontDb::global().add_font(variable_font().unwrap());
|
|
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: "Bold".into(), ..style(48.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
|
let back = crate::import_with_report(&pdf(&d, false).bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap().document;
|
|
let mut styles = vec![];
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
styles.extend(t.runs.iter().map(|r| r.style.font_style.clone()));
|
|
}
|
|
});
|
|
assert_eq!(styles, ["Bold"]);
|
|
}
|