Fix #3964: Create Outlines, EPS, EMF/WMF and legacy .ai keep the ruby where the canvas draws it

This commit is contained in:
Taha-Mahmoodi
2026-10-24 17:29:32 +07:00
committed by bflatastic
parent 7f8bc837bd
commit cab62cbb18
12 changed files with 145 additions and 18 deletions
+2 -1
View File
@@ -153,7 +153,8 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::Bez
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
// Ruby (furigana) glyphs are painted with their base text's run, as the renderer paints them.
for g in layout.glyphs.iter().chain(&layout.ruby) {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
+1 -1
View File
@@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines",
},
];
+39
View File
@@ -580,6 +580,18 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
node.appearance = st.appearance().into();
children.push(Arc::new(node));
}
// Ruby (furigana) glyphs, where the layout put them, in their base text's run's paint,
// as the canvas draws them (colour glyphs in ruby draw nothing there either).
for g in &lay.ruby {
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
}
let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, path, None);
node.appearance = st.appearance().into();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
@@ -2179,6 +2191,33 @@ mod ruby_tests {
assert!(text_of(&s, id).ruby.is_empty());
}
/// Create Outlines keeps the ruby: one path per inked glyph of the base text and of the
/// reading, each where the layout (and so the canvas) puts it.
#[test]
fn create_outlines_keeps_the_ruby_where_it_is_laid_out() {
use kurbo::Shape;
let (mut s, id) = session_with_text();
s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap();
let t = text_of(&s, id).clone();
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let inked = |gs: &[vectorcraft_text::PositionedGlyph]| -> Vec<kurbo::Rect> {
gs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box())).collect()
};
let (base, ruby) = (inked(&lay.glyphs), inked(&lay.ruby));
assert_eq!(ruby.len(), 3, "the reading's three kana have ink");
let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let paths: Vec<kurbo::Rect> = d.node(NodeId(g)).unwrap().children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
assert_eq!(paths.len(), base.len() + ruby.len(), "a path per glyph, the reading's included: {paths:?}");
let near = |a: &kurbo::Rect, b: &kurbo::Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.05);
for r in &ruby {
assert!(paths.iter().any(|p| near(p, r)), "the kana at {r:?} among {paths:?}");
}
// Undo brings the type back with its ruby.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_of(&s, id).ruby.len(), 1);
}
/// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present
/// (a reader's `<text>` can't place the reading), PDF differs when the ruby is there.
#[test]
+8 -5
View File
@@ -49,7 +49,7 @@
//! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
//! Windows-1252, vertical type, ruby, inline graphics, gradient or pattern fills) is written as its
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
//! names and the title are written in Windows-1252 too, and a character outside it is written as
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
@@ -82,7 +82,7 @@ const MAX_IMAGE_PIXELS: u64 = 1 << 26;
const HEX_LINE: usize = 60;
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted lists) are left out";
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
@@ -1268,7 +1268,8 @@ impl<'a> Writer<'a> {
self.line("u");
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
// Ruby (furigana) glyphs in their base text's run's paint, as the canvas draws them.
for g in layout.glyphs.iter().chain(&layout.ruby).filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
if bp.elements().is_empty() {
@@ -1371,11 +1372,13 @@ impl<'a> Writer<'a> {
}
}
/// Can type `t` be written as text: horizontal, its runs plain characters (no inline graphics)
/// that 8-bit text can hold, filled and stroked with colours?
/// Can type `t` be written as text: horizontal, without ruby (which a text object can't hold), its
/// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with
/// colours?
fn writable(t: &TextObject) -> bool {
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
!t.vertical
&& t.ruby.is_empty()
&& t.runs.iter().any(|r| !r.text.is_empty())
&& t.runs.iter().all(|r| {
r.inline.is_none()
+4
View File
@@ -596,6 +596,10 @@ impl<'a> Scene<'a> {
self.node(&art, true);
}
}
// Ruby (furigana) glyphs in their base text's run's paint, as the canvas draws them.
for g in layout.ruby.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
if bp.elements().is_empty() {
continue;
}
+22
View File
@@ -536,3 +536,25 @@ fn colr_glyphs_are_vector_art() {
assert!(ps.contains("1 0 0 rg"), "the red layer: {ps}");
assert!(ps.contains("0 0 1 rg"), "the foreground layer in the fill: {ps}");
}
/// Ruby (furigana) is written with its base text, as the canvas draws it: one more `m` per contour
/// of the reading's glyphs.
#[test]
fn ruby_is_written_with_its_type() {
use vectorcraft_doc::{CharStyle, RubySpan, TextObject};
let typed = |ruby: bool| {
let mut t = TextObject::point(Point::new(20.0, 100.0), "AB", CharStyle { size: 30.0, ..CharStyle::default() });
if ruby {
t.ruby = vec![RubySpan::new(0, 2, "xyz")];
}
t
};
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &typed(true));
let reading = lay.ruby.iter().flat_map(|g| g.outline.elements()).filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
assert!(reading >= 3, "the reading has ink: {reading}");
let moves = |ruby: bool| {
let d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(typed(ruby))))]);
page_of(&d, &opts(&d)).lines().filter(|l| l.ends_with(" m")).count()
};
assert_eq!(moves(true), moves(false) + reading, "the reading's contours are written");
}
+34 -4
View File
@@ -99,9 +99,9 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
/// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted
/// in their first stop's color.
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type), and
// its colour bitmap glyphs as images (their pixels added to `images`), its COLR glyphs as their
// layers.
// A run's glyphs, laid out, with its ruby's, as one path with the run's fill (the renderer
// draws no type), and its colour bitmap glyphs as images (their pixels added to `images`), its
// COLR glyphs as their layers.
fn outlined(t: &vectorcraft_doc::TextObject, images: &mut std::collections::BTreeMap<String, ImageBlob>) -> Node {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut nodes: Vec<Arc<Node>> = t
@@ -110,7 +110,7 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
.enumerate()
.map(|(i, run)| {
let mut bp = kurbo::BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
for g in layout.glyphs.iter().chain(&layout.ruby).filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let path = PathData::from_bezpath(&bp).transformed(t.xf);
@@ -1114,6 +1114,36 @@ fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() {
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
/// Ruby (furigana), which a text object can't hold, makes type 8-bit text could hold outlines,
/// with the reading's glyphs among them, where the canvas draws them (the file prints as the
/// document draws).
#[test]
fn type_with_ruby_is_outlines_with_the_reading() {
use vectorcraft_doc::{RubySpan, TextObject};
let typed = |ruby: bool| {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 60.0), "AB", style(30.0, red()));
if ruby {
t.ruby = vec![RubySpan::new(0, 2, "xyz")];
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let reading = lay.ruby.iter().flat_map(|g| g.outline.elements()).filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
(doc, reading)
};
let moves = |text: &str| text.lines().filter(|l| l.ends_with(" m")).count();
let (doc, reading) = typed(true);
assert!(reading >= 3, "the reading has ink: {reading}");
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
let with_ruby = String::from_utf8(out.bytes).unwrap();
assert!(!with_ruby.contains(" To\n"), "not a text object");
let plain = String::from_utf8(write_with(&typed(false).0, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap();
assert_eq!(moves(&with_ruby), moves(&plain) + reading, "the reading's contours are written");
}
/// A layer `Art` holding a group of a shown square and a hidden one; a hidden layer `Off` holding
/// a hidden square.
fn with_hidden() -> Document {
+23
View File
@@ -264,6 +264,29 @@ fn type_is_written_as_outlines() {
assert!(out.warnings.iter().any(|w| w.contains("type is written as outlines")), "{:?}", out.warnings);
}
/// Ruby (furigana) is written with its base text, as the canvas draws it: one more `MoveToEx`
/// per contour of the reading's glyphs in EMF, more polygons in WMF.
#[test]
fn ruby_is_written_with_its_type() {
let typed = |ruby: bool| {
let mut t = TextObject::point(Point::new(10.0, 60.0), "AB", CharStyle { size: 30.0, fill: red(), ..CharStyle::default() });
if ruby {
t.ruby = vec![vectorcraft_doc::RubySpan::new(0, 2, "xyz")];
}
t
};
let contours = |gs: &[vectorcraft_text::PositionedGlyph]| {
gs.iter().flat_map(|g| g.outline.elements()).filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count()
};
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &typed(true));
let reading = contours(&lay.ruby);
assert!(reading >= 3, "the reading has ink: {reading}");
let doc = |ruby: bool| doc_with(100.0, 100.0, vec![Node::new(NodeId(0), NodeKind::Text(Box::new(typed(ruby))))]);
let moves = |d: &Document| count(&kinds(&emf_records(&emf(d).bytes)), crate::emf::MOVETOEX);
assert_eq!(moves(&doc(true)), moves(&doc(false)) + reading, "the reading's contours are written");
assert!(wmf(&doc(true)).bytes.len() > wmf(&doc(false)).bytes.len(), "WMF writes the reading too");
}
#[test]
fn wmf_placeable_header_checksum_and_record_sizes() {
let d = doc_with(72.0, 144.0, vec![path(shapes::ellipse(Rect::new(10.0, 10.0, 60.0, 40.0)), red(), blue(), 1.0)]);
File diff suppressed because one or more lines are too long
+4 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long