mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Merge pull request #4547 from ham9zah/fix/ai-versioned-content
Import: dimension labels from Illustrator files come in once, right to left
This commit is contained in:
@@ -92,6 +92,11 @@ impl<'a> Lexer<'a> {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// Look for the next token at `pos` again (the end of the data at most).
|
||||
pub fn seek(&mut self, pos: usize) {
|
||||
self.pos = pos.min(self.src.len());
|
||||
}
|
||||
|
||||
/// The data from `from` to `to` (empty where that isn't in it).
|
||||
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
|
||||
self.src.get(from..to).unwrap_or_default()
|
||||
|
||||
@@ -69,19 +69,25 @@ pub(super) fn palette(data: &[u8]) -> Palette {
|
||||
let mut cell: Option<Cell> = None;
|
||||
// Inside a gradient's `Bb` … `BB`.
|
||||
let mut in_gradient = false;
|
||||
// Inside versioned content's alternate, which repeats what came before it.
|
||||
let mut alternate = false;
|
||||
// How deep in versioned content, and the depth of the block whose later alternatives (which
|
||||
// repeat its first) are being skipped, nested blocks in them included.
|
||||
let (mut depth, mut skipping) = (0usize, None::<usize>);
|
||||
while let Some(t) = lex.next_token() {
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
if line.contains("_Alternate_Content") {
|
||||
alternate = true;
|
||||
} else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") {
|
||||
alternate = false;
|
||||
if line.contains("_Begin_Content_if_version") {
|
||||
depth = depth.saturating_add(1);
|
||||
} else if line.contains("_Alternate_Content") {
|
||||
skipping = skipping.or(Some(depth));
|
||||
} else if line.contains("_End_Versioned_Content") {
|
||||
if skipping == Some(depth) {
|
||||
skipping = None;
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alternate {
|
||||
if skipping.is_some() {
|
||||
continue;
|
||||
}
|
||||
let w = match t.tok {
|
||||
@@ -211,6 +217,19 @@ mod tests {
|
||||
assert_eq!(p.swatches.len(), 1);
|
||||
}
|
||||
|
||||
/// Three alternatives, and versioned content nested in a later one: still one swatch.
|
||||
#[test]
|
||||
fn versioned_swatches_with_more_alternatives_are_read_once() {
|
||||
let p = palette(
|
||||
b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
|
||||
%AI17_Alternate_Content_if_version_gt:24 10\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
|
||||
%AI17_Begin_Content_if_version_gt:23 1\n0 0 0 1 k\n(Black)\nPc\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\
|
||||
0 0 1 0 k\n(Yellow)\nPc\n\
|
||||
%AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\n0 0 0 1 k\n(After)\nPc\nPB\n",
|
||||
);
|
||||
assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Red ink", "After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_doesnt_know_leaves_the_other_swatches() {
|
||||
let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n");
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(crate) use document::{slot_frame, slot_of};
|
||||
pub use types::IMAGE_TOO_LARGE;
|
||||
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster};
|
||||
#[cfg(test)]
|
||||
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD};
|
||||
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
|
||||
#[path = "legacy_text.rs"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::art::{four, sane, two};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
|
||||
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -69,6 +69,18 @@ impl<'a> Reader<'a> {
|
||||
if m == "BeginEncoding" {
|
||||
self.type_state.encoding_comment(line);
|
||||
}
|
||||
// Versioned content holds alternatives of the same art, the newest first: the first is
|
||||
// read, and the next one (`%AI17_Alternate_Content`, or `…_if_version_gt:` for a version
|
||||
// in between) starts what is skipped, up to the end of the block, nested blocks included.
|
||||
if m.starts_with("Alternate_Content") {
|
||||
let at = self.lex.offset();
|
||||
if self.skip_to("End_Versioned_Content").is_err() {
|
||||
// A block that doesn't end: the rest of the file is read rather than lost.
|
||||
self.lex.seek(at);
|
||||
self.warn(VERSIONED_UNENDED);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
return self.skip_to(end);
|
||||
}
|
||||
|
||||
@@ -244,9 +244,12 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[
|
||||
("BeginSymbolList", "EndSymbolList"),
|
||||
("BeginTextDocument", "EndTextDocument"),
|
||||
("BeginEncoding", "EndEncoding"),
|
||||
("Alternate_Content", "End_Versioned_Content"),
|
||||
];
|
||||
|
||||
/// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every
|
||||
/// alternative after the first is read too, so some art may come in twice.
|
||||
pub const VERSIONED_UNENDED: &str = "versioned content that doesn't end was read whole: some art may come in twice";
|
||||
|
||||
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
|
||||
/// shows but doesn't edit) whose PDF can't be read.
|
||||
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
|
||||
|
||||
@@ -953,6 +953,66 @@ fn sections_it_skips() {
|
||||
assert_eq!(art(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart.
|
||||
fn alt_square(i: u32) -> String {
|
||||
let x = f64::from(i) * 10.0;
|
||||
rect_path(x, 0.0, x + 5.0, 5.0) + "f\n"
|
||||
}
|
||||
|
||||
/// The left edges of the layer's art, in order: which [`alt_square`]s came in.
|
||||
fn squares(doc: &Document) -> Vec<f64> {
|
||||
art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect()
|
||||
}
|
||||
|
||||
/// Versioned content holds alternatives of the same art for newer and older versions (newer
|
||||
/// files write a Dimension tool group, then the same group again for a version in between, then
|
||||
/// plain art): only the first alternative is read, so the art comes in once, however many
|
||||
/// alternatives follow and whatever versioned content is nested in them.
|
||||
#[test]
|
||||
fn versioned_content_reads_one_alternative() {
|
||||
let (begin, between, alternate, end) = (
|
||||
"%AI17_Begin_Content_if_version_gt:24 11\n",
|
||||
"%AI17_Alternate_Content_if_version_gt:24 10\n",
|
||||
"%AI17_Alternate_Content\n",
|
||||
"%AI17_End_Versioned_Content\n",
|
||||
);
|
||||
let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3));
|
||||
let cases = [
|
||||
("one alternative", format!("{begin}{a}{end}"), vec![10.0]),
|
||||
("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]),
|
||||
("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]),
|
||||
// Blocks nested in the first alternative read their own first; those in a later one aren't read.
|
||||
(
|
||||
"nested",
|
||||
format!(
|
||||
"{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}",
|
||||
alt_square(4),
|
||||
alt_square(5),
|
||||
alt_square(6),
|
||||
alt_square(7)
|
||||
),
|
||||
vec![10.0, 20.0],
|
||||
),
|
||||
// Art after the block is read as usual.
|
||||
("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]),
|
||||
];
|
||||
for (case, body, want) in cases {
|
||||
let s = read_ok(&layer(&body));
|
||||
assert_eq!(squares(&s.doc), want, "{case}");
|
||||
assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest
|
||||
/// of the file.
|
||||
#[test]
|
||||
fn versioned_content_that_does_not_end_is_read_with_a_note() {
|
||||
let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2));
|
||||
let s = read_ok(&layer(&body));
|
||||
assert_eq!(squares(&s.doc), [10.0, 20.0]);
|
||||
assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
|
||||
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
|
||||
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
/// Longest story read (bytes).
|
||||
@@ -618,7 +618,7 @@ impl Story {
|
||||
let r = match self.frames.first()? {
|
||||
FrameKind::Area(s) => bounds(s)?,
|
||||
FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5),
|
||||
FrameKind::Point(_) => {
|
||||
FrameKind::Point(m) => {
|
||||
let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
||||
let reach = longest * size + size;
|
||||
let points: Vec<Point> = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect();
|
||||
@@ -626,7 +626,19 @@ impl Story {
|
||||
let (x, y) = self.place(template);
|
||||
to_doc * Point::new(x, y)
|
||||
});
|
||||
points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5)
|
||||
// Along the baseline as far as a line reaches, across it a line and a half: turned
|
||||
// with the type (a dimension's label turned a quarter runs down the page).
|
||||
// (The canvas is y down, the art y up: see [`to_art`].)
|
||||
let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO;
|
||||
let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) };
|
||||
let across = kurbo::Vec2::new(-along.y, along.x);
|
||||
let mut r = Rect::from_points(first, first);
|
||||
for p in points.iter().chain([&first]) {
|
||||
for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] {
|
||||
r = r.union_pt(*p + along * t + across * (n * size));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
r.is_finite().then_some(r)
|
||||
@@ -790,6 +802,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
|
||||
space_before: p.space_before * scale,
|
||||
space_after: p.space_after * scale,
|
||||
hyphenate: p.hyphenate,
|
||||
// A right-to-left paragraph lays its text out from the right whatever letters it has
|
||||
// (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter.
|
||||
direction: p.rtl.then_some(ParaDirection::RightToLeft),
|
||||
..ParaStyle::default()
|
||||
}
|
||||
}
|
||||
@@ -838,6 +853,37 @@ mod tests {
|
||||
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
|
||||
}
|
||||
|
||||
/// Where point type lies runs along its baseline: a label turned a quarter (as a vertical
|
||||
/// dimension's is) lies in a tall box, not a wide one.
|
||||
#[test]
|
||||
fn point_type_lies_along_its_baseline() {
|
||||
let para =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] }
|
||||
.para(None);
|
||||
let mut s = story(" 80 mm ", &[0.0], ¶);
|
||||
let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!(w > 3.0 * h, "level: {w} × {h}");
|
||||
// Turned −90° on the canvas (y down): the baseline runs down the page.
|
||||
s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])];
|
||||
let (w, h) = size(&s);
|
||||
assert!(h > 3.0 * w, "turned: {w} × {h}");
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph is laid out right to left even when it has no right-to-left
|
||||
/// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one
|
||||
/// that doesn't say, goes by its letters.
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_set_their_direction() {
|
||||
let texts =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
|
||||
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction;
|
||||
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
|
||||
assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own");
|
||||
let plain = Texts { para_defaults: Val::Dict(BTreeMap::new()), ..texts };
|
||||
assert_eq!(para_style(&plain.para(None), 1.0).direction, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
|
||||
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layers::closed_where_ends_meet;
|
||||
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type};
|
||||
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words};
|
||||
use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind};
|
||||
|
||||
/// Match page objects to the nearest slot whose story describes them, including hidden slots.
|
||||
@@ -103,28 +103,28 @@ pub(super) fn plan(
|
||||
// with its words? (A copied object's lines are where its original's are, so they don't say.)
|
||||
let hidden_type = |object: &[Arc<Node>]| {
|
||||
let Some(r) = extent(object) else { return false };
|
||||
let words: Vec<String> =
|
||||
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
|
||||
let texts: Vec<Vec<String>> =
|
||||
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text())) } else { None }).collect();
|
||||
hidden_at.iter().any(|(k, place)| {
|
||||
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
|
||||
&& lines
|
||||
.get(*k)
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
&& lines.get(*k).and_then(Option::as_ref).is_some_and(|l| {
|
||||
!texts.is_empty()
|
||||
&& texts.iter().all(|ws| !ws.is_empty() && ws.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
})
|
||||
})
|
||||
};
|
||||
// The letters of each slot's story, in lower case.
|
||||
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
|
||||
// For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in
|
||||
// its story? All are false unless such slots have the letters of every text between them.
|
||||
// For each slot: is its made type where `object` is, with the words of one of `object`'s texts in
|
||||
// its story? All are false unless such slots have the words of every text between them.
|
||||
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
|
||||
let r = extent(object);
|
||||
let words: Vec<String> = object
|
||||
let texts: Vec<Vec<String>> = object
|
||||
.iter()
|
||||
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None })
|
||||
.filter(|w| !w.is_empty())
|
||||
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None })
|
||||
.filter(|ws| !ws.is_empty())
|
||||
.collect();
|
||||
let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str())));
|
||||
let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str()))));
|
||||
let near: Vec<bool> = content
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -133,7 +133,7 @@ pub(super) fn plan(
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
|
||||
let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
|
||||
if every { near } else { vec![] }
|
||||
};
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
||||
@@ -150,7 +150,7 @@ pub(super) fn plan(
|
||||
Some(c) => *c = Content::Page(object.clone()),
|
||||
None => {}
|
||||
},
|
||||
// What lies where made type is and has that type's letters is that type as the page draws it.
|
||||
// What lies where made type is and has that type's words is that type as the page draws it.
|
||||
None if covered(object, &content, spells) => {
|
||||
let r = extent(object).unwrap_or(Rect::ZERO);
|
||||
for (k, c) in content.iter_mut().enumerate() {
|
||||
|
||||
@@ -323,6 +323,13 @@ pub(super) fn letters(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect()
|
||||
}
|
||||
|
||||
/// The [`letters`] of each word of `s`, the empty ones left out. Type is compared word by word:
|
||||
/// the page draws a right-to-left paragraph's words in the order they show (" 120.5 mm " as
|
||||
/// "mm 120.5"), its text object keeps them in the order they are typed.
|
||||
pub(super) fn words(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(letters).filter(|w| !w.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it
|
||||
/// (the page splits a line where its kerning or its styles change), on that line's baseline and
|
||||
/// after its start? How far from where the line starts it is, if so.
|
||||
|
||||
@@ -779,6 +779,25 @@ fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph's page type has its words in the order they show, not the order they
|
||||
/// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text
|
||||
/// object's type as the page draws it, not a second copy of it with the nearest text object.
|
||||
#[test]
|
||||
fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Page type of no text object that lies where the made type of two text objects is, with the
|
||||
/// letters of one of them in each of its pieces, is the type of both as the page draws it.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user