mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:
- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
the end of the path or once round a closed one; `TextKind::path_span`). Flipping
(`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
one undo step; with none of them it queries. The menu item opens the dialog with the selected
type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
bracket sets where the type runs, dragging the centre bracket slides it along the path and,
across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
`text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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) },
|
|
TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) } },
|
|
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } },
|
|
];
|
|
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"]);
|
|
}
|