mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 18:54:57 +00:00
Merge main into fix/shape-builder-merge-off-ui-thread
This commit is contained in:
Generated
+1
@@ -6338,6 +6338,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
@@ -611,6 +611,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option<char> {
|
||||
fn unicode_char(u: &str) -> Option<char> {
|
||||
let u = u.trim();
|
||||
let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u);
|
||||
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t')
|
||||
}
|
||||
|
||||
@@ -843,3 +846,18 @@ fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::unicode_char;
|
||||
|
||||
#[test]
|
||||
fn unicode_char_rejects_sign_in_hex_digits() {
|
||||
assert_eq!(unicode_char("+B0"), None);
|
||||
assert_eq!(unicode_char("U++B0"), None);
|
||||
assert_eq!(unicode_char("U+"), None);
|
||||
assert_eq!(unicode_char("00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("U+00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("0x00B0"), Some('°'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2381,6 +2381,53 @@ proptest! {
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// Damaged or hostile TrueType data in a Type 42 font that a composite font shows (its table
|
||||
/// directory and its glyphs), any glyph index: drawn, measured and outlined, or left out.
|
||||
#[test]
|
||||
fn eps_type42_glyphs_never_panic(
|
||||
head in prop::collection::vec((0usize..2048, any::<u8>()), 0..12),
|
||||
body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::<u8>()), 0..12),
|
||||
gid in 0u32..70_000,
|
||||
) {
|
||||
let mut font = TYPE42_FONT.to_vec();
|
||||
for (at, b) in head.into_iter().chain(body) {
|
||||
if let Some(x) = font.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
let text = type42_composite_eps(&font, gid);
|
||||
survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType font [`type42_composite_eps`] carries.
|
||||
const TYPE42_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// An EPS showing glyph `gid` of TrueType font `font` through a Type 42 font and a composite font
|
||||
/// over it, and measuring and outlining it.
|
||||
fn type42_composite_eps(font: &[u8], gid: u32) -> String {
|
||||
let sfnts: String = font.chunks(32_000).map(|c| format!("<{}>\n", c.iter().map(|b| format!("{b:02x}")).collect::<String>())).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put\n\
|
||||
/CharStrings 2 dict def CharStrings begin /.notdef 0 def /c01 {gid} def end /sfnts [\n{sfnts}] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def /Encoding [0] def\n\
|
||||
/FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 40 scalefont setfont 10 20 moveto (\\000\\001\\000\\001) show (\\000\\001) stringwidth pop pop\n\
|
||||
10 60 moveto (\\000\\001) true charpath fill\nshowpage\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// [`type42_composite_eps`] as it is: the glyph draws as filled outlines, not as type.
|
||||
#[test]
|
||||
fn the_type42_fixture_draws_its_glyphs() {
|
||||
let r = vectorcraft_eps::import(type42_composite_eps(TYPE42_FONT, 9).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let mut kinds = vec![];
|
||||
r.document.walk(|n| kinds.push(n.kind_label()));
|
||||
assert!(!kinds.contains(&"Text") && kinds.iter().filter(|k| **k == "Path").count() >= 3, "{kinds:?}");
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
|
||||
@@ -17,6 +17,8 @@ vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
# The glyph outlines of Type 42 fonts a composite font shows.
|
||||
skrifa = { workspace = true }
|
||||
# Windows metafile previews, opened when a file's PostScript can't be read.
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
|
||||
|
||||
@@ -44,10 +44,13 @@ mod obj;
|
||||
mod preview;
|
||||
mod shading;
|
||||
mod text;
|
||||
mod type42;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_composite;
|
||||
#[cfg(test)]
|
||||
mod tests_fontnames;
|
||||
#[cfg(test)]
|
||||
mod tests_generators;
|
||||
|
||||
@@ -42,7 +42,7 @@ const MAX_DASH_ENTRIES: usize = 65_535;
|
||||
/// Most entries an indexed colour space's table has.
|
||||
const MAX_HIVAL: usize = 4095;
|
||||
/// Most elements the current path may have.
|
||||
const MAX_PATH: usize = 1 << 20;
|
||||
pub(crate) const MAX_PATH: usize = 1 << 20;
|
||||
/// Most path elements drawn in all (each object's clips and groups counting as elements too).
|
||||
const MAX_DRAWN: usize = 1 << 24;
|
||||
/// Samples taken of a shading function that isn't a plain interpolation.
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Interp<'_> {
|
||||
|
||||
// ---------- painting ----------
|
||||
|
||||
pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
|
||||
pub(in super::super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
|
||||
if bp.elements().is_empty() || self.g.null {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -73,6 +73,12 @@ pub(crate) struct Interp<'a> {
|
||||
pub fault: Option<Fault>,
|
||||
/// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`).
|
||||
pub glyph_width: Option<[f64; 2]>,
|
||||
/// The TrueType data of the Type 42 fonts drawn so far, by their `sfnts` array (see
|
||||
/// `type42`; `None` when it can't be read), the most recently used last.
|
||||
pub type42: Type42Cache,
|
||||
/// The `sfnts` arrays whose TrueType data has been counted against [`MAX_MEMORY`]: a font
|
||||
/// read again after it left `type42` doesn't count again.
|
||||
pub type42_counted: HashSet<Shared<Obj>>,
|
||||
pub g: GState,
|
||||
pub saved: Vec<GState>,
|
||||
pub out: Out,
|
||||
@@ -94,6 +100,9 @@ pub(crate) struct Fault {
|
||||
pub procs: Vec<Rc<str>>,
|
||||
}
|
||||
|
||||
/// The TrueType data of Type 42 fonts by their `sfnts` array (see [`Interp::type42`]).
|
||||
pub(crate) type Type42Cache = Vec<(Shared<Obj>, Option<Rc<Vec<u8>>>)>;
|
||||
|
||||
/// Most procedure names a [`Fault`] keeps.
|
||||
const FAULT_PROCS: usize = 3;
|
||||
|
||||
@@ -189,6 +198,8 @@ impl<'a> Interp<'a> {
|
||||
journal: vec![],
|
||||
fault: None,
|
||||
glyph_width: None,
|
||||
type42: vec![],
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
out,
|
||||
@@ -362,7 +373,7 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search`
|
||||
/// charges per 64 bytes, so a loop of big copies runs out of the budget.
|
||||
fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
pub fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
self.spend((n / 256) as u64)
|
||||
}
|
||||
|
||||
|
||||
@@ -1125,7 +1125,7 @@ fn read_within(body: &str, secs: u64) -> crate::Imported {
|
||||
/// budget. Running to the end of the budget can take over 20 s on a slow CI runner
|
||||
/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the
|
||||
/// limit here only catches a program that never stops.
|
||||
fn runs_out_of_budget(body: &str) {
|
||||
pub(super) fn runs_out_of_budget(body: &str) {
|
||||
let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120);
|
||||
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write a PDF's
|
||||
//! two-byte fonts: each character a byte picking the descendant font and a byte for the code in
|
||||
//! it (PLRM 3rd ed., §5.10). Type 42 descendants draw their glyphs from the TrueType font they
|
||||
//! carry (Adobe Technical Note #5012); the fonts here are written from scratch around the bundled
|
||||
//! Source Sans 3.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::tests::{all, bounds, fill, read};
|
||||
|
||||
const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// The glyph index of `c` in [`FONT`] and its advance width in ems.
|
||||
fn glyph(c: char) -> (u32, f64) {
|
||||
let font = FontRef::new(FONT).unwrap();
|
||||
let gid = font.charmap().map(c).unwrap();
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
let adv = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(gid).unwrap();
|
||||
(gid.to_u32(), f64::from(adv) / upem)
|
||||
}
|
||||
|
||||
/// `sfnts` strings of [`FONT`]: the first of odd length, ending in a padding byte that isn't part
|
||||
/// of the font, the rest split at even lengths.
|
||||
fn sfnts() -> String {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
let mut out = format!("<{}00>\n", hex(&FONT[..20000]));
|
||||
for chunk in FONT[20000..].chunks(40000) {
|
||||
out.push_str(&format!("<{}>\n", hex(chunk)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A program defining Type 42 font `/T42` (codes 1 and 2 are H and I, by glyph names that
|
||||
/// don't say which characters they are) and composite font `/Comp` over it.
|
||||
fn fonts() -> String {
|
||||
let (h, _) = glyph('H');
|
||||
let (i, _) = glyph('I');
|
||||
format!(
|
||||
"10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put Encoding 2 /c02 put\n\
|
||||
/CharStrings 3 dict def CharStrings begin /.notdef 0 def /c01 {h} def /c02 {i} def end\n\
|
||||
/sfnts [\n{}] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n",
|
||||
sfnts()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type42_descendants_draw_their_own_glyphs() {
|
||||
let (_, wh) = glyph('H');
|
||||
let (_, wi) = glyph('I');
|
||||
let r = read(&format!("{}/Comp findfont 50 scalefont setfont 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\002) show", fonts()));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let nodes = all(&r.document);
|
||||
assert!(!nodes.iter().any(|n| matches!(n.kind, NodeKind::Text(_))));
|
||||
let paths: Vec<_> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
for p in &paths {
|
||||
assert_eq!(fill(p), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
}
|
||||
// The H, then the I one H's width along; both stand on the baseline, a cap height tall.
|
||||
let (bh, bi) = (bounds(paths[0]), bounds(paths[1]));
|
||||
assert!((bh.x0 - 10.0).abs() < 8.0 && bh.width() > 20.0 && bh.width() < 50.0 * wh, "{bh:?}");
|
||||
assert!((bh.height() - bi.height()).abs() < 0.5 && bh.height() > 25.0 && bh.height() < 40.0, "{bh:?} {bi:?}");
|
||||
assert!(bi.x0 > 10.0 + 50.0 * wh - 1.0 && bi.x1 < 10.0 + 50.0 * (wh + wi), "{bi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type42_descendants_measure_space_and_outline_their_glyphs() {
|
||||
let (_, wh) = glyph('H');
|
||||
let (_, wi) = glyph('I');
|
||||
let check = |cond: String| super::tests::check(&format!("{} /Comp findfont 50 scalefont setfont {cond}", fonts()));
|
||||
// The width its font gives each glyph.
|
||||
check(format!("(\\000\\001\\000\\002) stringwidth pop {} sub abs 0.01 lt", 50.0 * (wh + wi)));
|
||||
// xyshow places each character where its numbers say.
|
||||
check("gsave nulldevice 10 20 moveto (\\000\\001\\000\\002) [30 0 7 0] xyshow currentpoint grestore pop 47 sub abs 0.01 lt".into());
|
||||
// charpath outlines them.
|
||||
check("newpath 10 20 moveto (\\000\\001) true charpath pathbbox exch pop sub abs 25 gt exch 10 sub abs 8 lt and".into());
|
||||
}
|
||||
|
||||
/// A descendant that is a font by name only (its program isn't run) sets its characters as type,
|
||||
/// at the size the composite font's matrix gives them, not at 1000 times it.
|
||||
#[test]
|
||||
fn named_descendants_set_type_at_the_composite_fonts_size() {
|
||||
let r = read(
|
||||
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Helvetica findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 30 scalefont setfont 10 20 moveto (\\000A\\000B) show",
|
||||
);
|
||||
let sizes: Vec<f64> = all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.iter().map(|r| r.style.size * t.xf.determinant().abs().sqrt()).sum::<f64>() / t.runs.len() as f64),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!sizes.is_empty() && sizes.iter().all(|s| (s - 30.0).abs() < 0.5), "{sizes:?}");
|
||||
let bounds_all = all(&r.document).iter().map(|n| bounds(n)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(bounds_all.width() < 60.0, "{bounds_all:?}");
|
||||
}
|
||||
|
||||
/// `widthshow` adds its spacing to the composite character whose font number times 256 plus code
|
||||
/// is its `char` (PLRM 3rd ed., `widthshow` in chapter 8), not to every character with that code.
|
||||
#[test]
|
||||
fn widthshow_matches_a_composite_character_by_font_number_and_code() {
|
||||
let (_, wh) = glyph('H');
|
||||
let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def");
|
||||
let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}"));
|
||||
let end = |ch: u32| format!("gsave nulldevice 10 20 moveto 20 0 {ch} (\\000\\001\\001\\001) widthshow currentpoint grestore pop");
|
||||
// 257 is font 1's code 1: the second H only.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(257), 10.0 + 100.0 * wh + 20.0));
|
||||
// 1 is font 0's code 1: the first H only.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(1), 10.0 + 100.0 * wh + 20.0));
|
||||
// 2 is neither.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh));
|
||||
}
|
||||
|
||||
/// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`)
|
||||
/// counts against the operation budget, as drawing them does.
|
||||
#[test]
|
||||
fn measuring_type42_descendants_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(&format!(
|
||||
"{} /Comp findfont 50 scalefont setfont /s 100000 string def {{ s stringwidth pop pop }} loop",
|
||||
fonts()
|
||||
));
|
||||
}
|
||||
|
||||
/// A descendant whose TrueType data can't be read (an `sfnts` element that isn't a string, after
|
||||
/// 32 MB of strings) is read once, not again for every character measured: copying its strings
|
||||
/// costs about 130,000 operations, so 1,000 measures finish only if it is copied once (reading
|
||||
/// it each time would need 130 million, over the budget of 50 million).
|
||||
#[test]
|
||||
fn unreadable_type42_data_is_read_once() {
|
||||
super::tests::check(
|
||||
"10 dict begin /FontName /Bad def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ 0 1 7 { pop 4000000 string } for 0 ] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Bad findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont 1000 { (\\000\\001) stringwidth pop pop } repeat true",
|
||||
);
|
||||
}
|
||||
|
||||
/// A Type 3 descendant draws and measures its glyphs with its own procedure, under the composite
|
||||
/// font's matrix: here 50 pt and twice as wide, so a glyph 500 × 700 units wide (600 units
|
||||
/// apart) is 50 × 35 pt, 60 pt apart (PLRM 3rd ed., §5.10: a descendant's glyphs are under the
|
||||
/// composite font's matrix as well as its own).
|
||||
#[test]
|
||||
fn type3_descendants_draw_and_measure_under_the_composite_matrix() {
|
||||
let fonts = "8 dict begin /FontName /T3 def /FontType 3 def /FontMatrix [0.001 0 0 0.001 0 0] def /FontBBox [0 0 1000 1000] def\n\
|
||||
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 1 /box put\n\
|
||||
/BuildChar { exch begin 1 eq { 600 0 0 0 500 700 setcachedevice 0 0 500 700 rectfill } { 0 0 setcharwidth } ifelse end } def\n\
|
||||
FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [2 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/T3 findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont";
|
||||
let r = read(&format!("{fonts} 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\001) show"));
|
||||
let paths: Vec<_> = all(&r.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(paths.len(), 2, "{:?}", r.warnings);
|
||||
let (a, b) = (bounds(&paths[0]), bounds(&paths[1]));
|
||||
for g in [a, b] {
|
||||
assert!((g.width() - 50.0).abs() < 0.01 && (g.height() - 35.0).abs() < 0.01, "{g:?}");
|
||||
}
|
||||
assert!((a.x0 - 10.0).abs() < 0.01 && (b.x0 - 70.0).abs() < 0.01, "{a:?} {b:?}");
|
||||
let check = |cond: &str| super::tests::check(&format!("{fonts} {cond}"));
|
||||
check("(\\000\\001\\000\\001) stringwidth pop 120 sub abs 0.01 lt");
|
||||
check("newpath 10 20 moveto (\\000\\001) true charpath currentpoint pop 70 sub abs 0.01 lt");
|
||||
}
|
||||
|
||||
/// A descendant found by name is scaled for each character it sets: copying a huge font
|
||||
/// dictionary for every character counts against the operation budget, so a `loop` of it stops.
|
||||
#[test]
|
||||
fn a_huge_named_descendant_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(
|
||||
"200000 dict begin 0 1 199999 { dup def } for /FontName /Big def /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def\n\
|
||||
FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Big findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont { (\\000A) stringwidth pop pop } loop",
|
||||
);
|
||||
}
|
||||
|
||||
/// A composite font's TrueType data stays read while it is in use: the font a string uses for
|
||||
/// every other character isn't dropped (and read again, counting its size against the memory
|
||||
/// budget each time) while 16 other fonts are read between its uses.
|
||||
#[test]
|
||||
fn type42_data_in_use_stays_read() {
|
||||
let mut others = String::new();
|
||||
for k in 1..=16 {
|
||||
others.push_str(&format!(
|
||||
"10 dict begin /FontName /B{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [<00>] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (1..=16).map(|k| format!(" /B{k} findfont")).collect();
|
||||
let fonts = fonts().replace(
|
||||
"/Encoding [0] def /FDepVector [/T42 findfont] def",
|
||||
&format!("/Encoding [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16] def /FDepVector [/T42 findfont{deps}] def"),
|
||||
);
|
||||
let s: String = (1..=16).map(|k| format!("\\000\\001\\{k:03o}\\001")).collect();
|
||||
super::tests::check(&format!("{others}{fonts} /Comp findfont 50 scalefont setfont 3000 {{ ({s}) stringwidth pop pop }} repeat true"));
|
||||
}
|
||||
|
||||
/// A page that cycles through more Type 42 fonts than are kept read reads each again when it
|
||||
/// comes back, but counts its data against the memory budget once.
|
||||
#[test]
|
||||
fn type42_data_read_again_counts_once() {
|
||||
let mut fonts = format!("/S [\n{}] def\n", sfnts());
|
||||
for k in 0..17 {
|
||||
fonts.push_str(&format!(
|
||||
"10 dict begin /FontName /F{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop ] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (0..17).map(|k| format!(" /F{k} findfont")).collect();
|
||||
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
|
||||
fonts.push_str(&format!(
|
||||
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
|
||||
// 17 fonts of 431 kB, each read 200 times: 1.4 GB if each read counted.
|
||||
super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont 200 {{ ({s}) stringwidth pop pop }} repeat true"));
|
||||
}
|
||||
|
||||
/// Reading `sfnts` data that turns out unreadable is charged too: a composite font cycling
|
||||
/// through more such descendants than are kept read copies their strings on every glyph, and a
|
||||
/// `loop` of it runs out of the budget.
|
||||
#[test]
|
||||
fn unreadable_type42_data_read_again_runs_out_of_the_budget() {
|
||||
let mut fonts = String::from("/S [ 0 1 7 { pop 4000000 string } for ] def\n");
|
||||
for k in 0..17 {
|
||||
fonts.push_str(&format!(
|
||||
"10 dict begin /FontName /Bad{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop 0 ] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (0..17).map(|k| format!(" /Bad{k} findfont")).collect();
|
||||
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
|
||||
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
|
||||
super::tests::runs_out_of_budget(&format!(
|
||||
"{fonts}16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont {{ ({s}) stringwidth pop pop }} loop"
|
||||
));
|
||||
}
|
||||
+273
-22
@@ -168,9 +168,13 @@ impl Interp<'_> {
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
|
||||
let (s, spacing) = self.show_operands(op)?;
|
||||
match self.type3_font() {
|
||||
Some(font) => self.show_type3(&font, &s, &spacing)?,
|
||||
None => self.show(&s)?,
|
||||
if let Some(glyphs) = self.composite_glyphs(&s) {
|
||||
self.show_composite(&glyphs, &spacing)?;
|
||||
} else {
|
||||
match self.type3_font() {
|
||||
Some(font) => self.show_type3(&font, &s, &spacing)?,
|
||||
None => self.show(&s)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
CShow => {
|
||||
@@ -273,6 +277,13 @@ impl Interp<'_> {
|
||||
|
||||
/// How far `s` moves the current point (user space).
|
||||
fn width_of(&mut self, s: &[u8]) -> Res<Vec2> {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
let mut w = Vec2::ZERO;
|
||||
for g in &glyphs {
|
||||
w += self.composite_glyph(g, Point::new(w.x, w.y), Draw::No)?;
|
||||
}
|
||||
return Ok(w);
|
||||
}
|
||||
let Some(font) = self.type3_font() else { return Ok(self.set_type(s, Point::ZERO)?.2) };
|
||||
let mut w = Vec2::ZERO;
|
||||
for &code in s {
|
||||
@@ -287,19 +298,7 @@ impl Interp<'_> {
|
||||
for (i, &code) in s.iter().enumerate() {
|
||||
let at = self.user_point()?;
|
||||
let w = self.type3_glyph(font, Glyph::Code(code), at, true)?;
|
||||
let d = match spacing {
|
||||
Spacing::Plain | Spacing::Kern(_) => w,
|
||||
Spacing::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == i64::from(code)).map_or(Vec2::ZERO, |(_, v)| v),
|
||||
Spacing::Widths(v, op) => {
|
||||
let n = |k: usize| v.get(k).copied();
|
||||
match op {
|
||||
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
|
||||
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
|
||||
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
|
||||
}
|
||||
.unwrap_or(w)
|
||||
}
|
||||
};
|
||||
let d = spacing.advance(i, i64::from(code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
if let (Spacing::Kern(p), Some(next)) = (spacing, s.get(i + 1)) {
|
||||
self.push(Obj::Int(i64::from(code)))?;
|
||||
@@ -315,10 +314,15 @@ impl Interp<'_> {
|
||||
/// Glyph `glyph` of Type 3 font `font` drawn by its `BuildGlyph` (or `BuildChar`) procedure at
|
||||
/// user point `at` (only measured unless `paint`) → its width (user space).
|
||||
fn type3_glyph(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool) -> Res<Vec2> {
|
||||
self.type3_glyph_in(font, glyph, at, paint, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// [`Self::type3_glyph`] for a descendant of a composite font whose matrix is `outer`.
|
||||
fn type3_glyph_in(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool, outer: Affine) -> Res<Vec2> {
|
||||
let (fm, build_glyph, build_char, encoding) = {
|
||||
let f = font.borrow();
|
||||
let get = |k: &str| f.get(&Key::name(k)).cloned();
|
||||
let fm = get("FontMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
let fm = outer * font_matrix(&f, 0.001);
|
||||
(fm, get("BuildGlyph"), get("BuildChar"), get("Encoding"))
|
||||
};
|
||||
let code_of = |name: &str| -> Option<u8> {
|
||||
@@ -402,11 +406,16 @@ impl Interp<'_> {
|
||||
};
|
||||
let f = font.borrow();
|
||||
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
|
||||
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
|
||||
// (its FontMatrix, usually the identity, is already the em's).
|
||||
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
|
||||
Ok((name, m * Affine::scale(units)))
|
||||
// A composite font's glyphs are its descendants', in their glyph space under its matrix
|
||||
// (PLRM 3rd ed., §5.10 "Composite Fonts"): the first descendant stands for them all.
|
||||
if font_type(&f) == Some(0.0) {
|
||||
let first = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0));
|
||||
if let Some(Obj::Dict(d)) = first {
|
||||
let d = d.borrow();
|
||||
return Ok((name, font_matrix(&f, 1.0) * font_matrix(&d, 0.001) * Affine::scale(units(&d))));
|
||||
}
|
||||
}
|
||||
Ok((name, font_matrix(&f, 0.001) * Affine::scale(units(&f))))
|
||||
}
|
||||
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
@@ -451,6 +460,14 @@ impl Interp<'_> {
|
||||
|
||||
/// `charpath`: the glyph outlines of `s` added to the path.
|
||||
fn char_path(&mut self, s: &[u8]) -> Res {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
for g in &glyphs {
|
||||
let at = self.user_point()?;
|
||||
let w = self.composite_glyph(g, at, Draw::Path)?;
|
||||
self.g.cur = Some(self.xf() * (at + w));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let at = self.user_point()?;
|
||||
let (t, layout, adv) = self.set_type(s, at)?;
|
||||
for g in &layout.glyphs {
|
||||
@@ -484,6 +501,240 @@ enum Glyph {
|
||||
Name(Rc<str>),
|
||||
}
|
||||
|
||||
impl Spacing {
|
||||
/// How far glyph `i` (character code `code`, width `w`) moves the current point.
|
||||
fn advance(&self, i: usize, code: i64, w: Vec2) -> Vec2 {
|
||||
match self {
|
||||
Self::Plain | Self::Kern(_) => w,
|
||||
Self::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == code).map_or(Vec2::ZERO, |(_, v)| v),
|
||||
Self::Widths(v, op) => {
|
||||
let n = |k: usize| v.get(k).copied();
|
||||
match op {
|
||||
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
|
||||
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
|
||||
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
|
||||
}
|
||||
.unwrap_or(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character of a composite font: the descendant font that draws it, its code there, and the
|
||||
/// composite font's matrix (over the descendant's own).
|
||||
struct Mapped {
|
||||
font: DictRef,
|
||||
/// The byte that picked the descendant (the font number).
|
||||
pick: u8,
|
||||
code: u8,
|
||||
outer: Affine,
|
||||
}
|
||||
|
||||
/// What drawing a glyph does with it.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Draw {
|
||||
/// Fills it (`show`).
|
||||
Fill,
|
||||
/// Adds its outline to the current path (`charpath`).
|
||||
Path,
|
||||
/// Only measures it (`stringwidth`).
|
||||
No,
|
||||
}
|
||||
|
||||
/// Font `f`'s `FontType`.
|
||||
fn font_type(f: &Dict) -> Option<f64> {
|
||||
f.get(&Key::name("FontType")).and_then(Obj::as_num)
|
||||
}
|
||||
|
||||
/// Font `f`'s matrix, `default` times the identity when it has none.
|
||||
fn font_matrix(f: &Dict, default: f64) -> Affine {
|
||||
f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(default))
|
||||
}
|
||||
|
||||
/// Font `f`'s glyph units in its matrix's space: Type 1 glyphs are in 1000-unit space; a Type 42
|
||||
/// font's glyph space is one unit an em (its FontMatrix, usually the identity, is already the
|
||||
/// em's).
|
||||
fn units(f: &Dict) -> f64 {
|
||||
if font_type(f) == Some(42.0) { 1.0 } else { 1000.0 }
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// The characters of `s` in the current font when it is a composite font with the 8/8 mapping
|
||||
/// (`FMapType` 2: a byte picking the descendant through `Encoding` and `FDepVector`, then the
|
||||
/// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries"); `None` for other fonts and
|
||||
/// mappings, which are set as type in the font of the composite font's name.
|
||||
fn composite_glyphs(&self, s: &[u8]) -> Option<Vec<Mapped>> {
|
||||
let font = self.g.font.clone()?;
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(2.0) {
|
||||
return None;
|
||||
}
|
||||
let (encoding, fonts) = (f.get(&Key::name("Encoding"))?.items()?.clone(), f.get(&Key::name("FDepVector"))?.items()?.clone());
|
||||
let (encoding, fonts) = (encoding.borrow(), fonts.borrow());
|
||||
let outer = font_matrix(&f, 1.0);
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for &[pick, code] in s.as_chunks::<2>().0 {
|
||||
let index = encoding.get(usize::from(pick)).and_then(Obj::as_num).filter(|i| *i >= 0.0 && i.fract() == 0.0);
|
||||
// A character whose descendant is missing (or not a base font) shows nothing.
|
||||
if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize))
|
||||
&& font_type(&d.borrow()) != Some(0.0)
|
||||
{
|
||||
out.push(Mapped { font: d.clone(), pick, code, outer });
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
|
||||
fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res {
|
||||
let start = self.out.drawn.len();
|
||||
let mut text = String::new();
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
let at = self.user_point()?;
|
||||
let w = self.composite_glyph(g, at, Draw::Fill)?;
|
||||
// `widthshow` and `awidthshow` match a composite font's character by its font number
|
||||
// times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8).
|
||||
let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
if c != '\u{2022}' {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
if text.trim().is_empty()
|
||||
&& let Some(name) = self.g.font.as_ref().and_then(|f| f.borrow().get(&Key::name("FontName")).and_then(Obj::text))
|
||||
{
|
||||
text = name.to_string();
|
||||
}
|
||||
self.group_glyphs(start, &text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Character `g` of a composite font at user point `at`, drawn by its descendant: a Type 3
|
||||
/// font's procedure, a Type 42 font's TrueType outline, else as type in the font of the
|
||||
/// descendant's name (the character its glyph name stands for) → its width (user space).
|
||||
fn composite_glyph(&mut self, g: &Mapped, at: Point, paint: Draw) -> Res<Vec2> {
|
||||
let builds = {
|
||||
let f = g.font.borrow();
|
||||
font_type(&f) == Some(3.0) && (f.contains_key(&Key::name("BuildGlyph")) || f.contains_key(&Key::name("BuildChar")))
|
||||
};
|
||||
if builds {
|
||||
return self.type3_glyph_in(&g.font, Glyph::Code(g.code), at, paint == Draw::Fill, g.outer);
|
||||
}
|
||||
let fm = g.outer * font_matrix(&g.font.borrow(), 1.0);
|
||||
if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code)? {
|
||||
// Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it.
|
||||
self.spend((outline.elements().len() as u64).saturating_add(16))?;
|
||||
if paint != Draw::No {
|
||||
let mut p = outline;
|
||||
p.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm);
|
||||
if paint == Draw::Fill {
|
||||
self.fill_path(p, vectorcraft_geom::FillRule::NonZero)?;
|
||||
} else {
|
||||
if self.g.path.elements().len().saturating_add(p.elements().len()) > super::graphics::MAX_PATH {
|
||||
return Err(PsError::Limit("a path has too many points"));
|
||||
}
|
||||
self.g.path.extend(p);
|
||||
}
|
||||
}
|
||||
return Ok(fm * Point::new(advance, 0.0) - fm * Point::ZERO);
|
||||
}
|
||||
// Set as type in the descendant's font, under the composite font's matrix.
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
let Ok(byte) = u8::try_from(u32::from(c)) else { return Ok(Vec2::ZERO) };
|
||||
// Scaling the descendant copies its dictionary for every character: charged by its size,
|
||||
// so a huge font dictionary shown in a loop runs out of the budget.
|
||||
let entries = g.font.borrow().len();
|
||||
self.spend(entries as u64)?;
|
||||
let saved = self.g.font.replace(transformed(&g.font, g.outer));
|
||||
let r = match paint {
|
||||
Draw::Fill => {
|
||||
self.g.cur = Some(self.xf() * at);
|
||||
self.show(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
|
||||
}
|
||||
Draw::Path => {
|
||||
self.g.cur = Some(self.xf() * at);
|
||||
self.char_path(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
|
||||
}
|
||||
Draw::No => self.set_type(&[byte], at).map(|t| t.2),
|
||||
};
|
||||
self.g.font = saved;
|
||||
r
|
||||
}
|
||||
|
||||
/// Glyph `code` of Type 42 font `font` from the TrueType font it carries: its outline and
|
||||
/// width in its glyph space (ems), or `None` when it isn't a Type 42 font with one.
|
||||
fn type42_glyph(&mut self, font: &DictRef, code: u8) -> Res<Option<(vectorcraft_geom::BezPath, f64)>> {
|
||||
let (sfnts, gid) = {
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(42.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(Obj::Array { items: sfnts, .. }) = f.get(&Key::name("sfnts")).cloned() else { return Ok(None) };
|
||||
let name = glyph_name(&f, code);
|
||||
let strings = f.get(&Key::name("CharStrings")).and_then(|c| match c {
|
||||
Obj::Dict(d) => Some(d.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let gid = strings
|
||||
.and_then(|d| {
|
||||
let d = d.borrow();
|
||||
d.get(&Key::name(&name)).or_else(|| d.get(&Key::name(".notdef"))).and_then(Obj::as_num)
|
||||
})
|
||||
.filter(|g| (0.0..=f64::from(u16::MAX)).contains(g))
|
||||
.map_or(0, |g| g as u32);
|
||||
(sfnts, gid)
|
||||
};
|
||||
// The least recently used font goes first: a hit moves to the end.
|
||||
let data = match self.type42.iter().position(|(k, _)| k.same(&sfnts)) {
|
||||
Some(i) => {
|
||||
let hit = self.type42.remove(i);
|
||||
let data = hit.1.clone();
|
||||
self.type42.push(hit);
|
||||
data
|
||||
}
|
||||
None => {
|
||||
// Joining the strings visits each element and copies its bytes, whether or not
|
||||
// the font can then be read: charged first, as other bulk copies are.
|
||||
let (elements, bytes) = super::type42::font_size(&sfnts.borrow());
|
||||
self.spend_bytes(bytes)?;
|
||||
self.spend((elements / 16) as u64)?;
|
||||
// Fonts whose data can't be read are kept too, so they are copied only once.
|
||||
let data = super::type42::font_data(&sfnts.borrow()).map(Rc::new);
|
||||
if let Some(d) = &data
|
||||
&& self.type42_counted.insert(sfnts.clone())
|
||||
{
|
||||
self.alloc(d.len())?;
|
||||
}
|
||||
if self.type42.len() >= MAX_TYPE42 {
|
||||
self.type42.remove(0);
|
||||
}
|
||||
self.type42.push((sfnts, data.clone()));
|
||||
data
|
||||
}
|
||||
};
|
||||
let Some(data) = data else { return Ok(None) };
|
||||
Ok(super::type42::glyph(&data, gid))
|
||||
}
|
||||
}
|
||||
|
||||
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
|
||||
const MAX_TYPE42: usize = 16;
|
||||
|
||||
/// The character code `code` of font `f` stands for: its glyph name's, or the code read as
|
||||
/// Latin-1 when the font names no glyph for it (a font found by name, whose program isn't run).
|
||||
fn code_char(f: &Dict, code: u8) -> char {
|
||||
match &*glyph_name(f, code) {
|
||||
".notdef" => char::from(code),
|
||||
name => glyph_char(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph name font `f`'s `Encoding` gives code `code` (`.notdef` without one).
|
||||
fn glyph_name(f: &Dict, code: u8) -> Rc<str> {
|
||||
f.get(&Key::name("Encoding")).and_then(|e| e.items()?.get(usize::from(code))).and_then(|o| o.text()).unwrap_or_else(|| Rc::from(".notdef"))
|
||||
}
|
||||
|
||||
/// A font scaled or transformed by `m` (`scalefont`, `makefont`).
|
||||
fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
let mut d = font.borrow().clone();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Type 42 fonts' glyphs drawn from the TrueType font they carry, for the type a composite font
|
||||
//! shows: its glyphs are picked by glyph index, often in a subset font without a character map,
|
||||
//! so they can't be set again as type in an installed font. The font is the `sfnts` strings
|
||||
//! joined, each string of odd length ending in a padding byte that isn't part of it (Adobe
|
||||
//! Technical Note #5012, "The Type 42 Font Format Specification", on the `sfnts` array); a glyph
|
||||
//! name maps to its glyph index through `CharStrings`.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{FontRef, GlyphId, MetadataProvider};
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
use super::obj::Obj;
|
||||
|
||||
/// Most bytes of TrueType data a Type 42 font may carry.
|
||||
pub(super) const MAX_FONT: usize = 32 << 20;
|
||||
|
||||
/// The TrueType font of a Type 42 font's `sfnts` strings, or `None` when an element isn't a
|
||||
/// string or the font would be larger than [`MAX_FONT`].
|
||||
pub(super) fn font_data(sfnts: &[Obj]) -> Option<Vec<u8>> {
|
||||
let mut data = Vec::new();
|
||||
for s in sfnts {
|
||||
let Obj::Str(s) = s else { return None };
|
||||
let s = s.borrow();
|
||||
let len = s.len() - s.len() % 2;
|
||||
if data.len() + len > MAX_FONT {
|
||||
return None;
|
||||
}
|
||||
data.extend_from_slice(s.get(..len).unwrap_or_default());
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// What [`font_data`] visits for `sfnts`: the elements up to the first that isn't a string, and
|
||||
/// the bytes of those strings (at most [`MAX_FONT`] and one string more).
|
||||
pub(super) fn font_size(sfnts: &[Obj]) -> (usize, usize) {
|
||||
let mut bytes = 0usize;
|
||||
for (i, s) in sfnts.iter().enumerate() {
|
||||
let Obj::Str(s) = s else { return (i + 1, bytes) };
|
||||
bytes = bytes.saturating_add(s.borrow().len());
|
||||
if bytes > MAX_FONT {
|
||||
return (i + 1, bytes.min(2 * MAX_FONT));
|
||||
}
|
||||
}
|
||||
(sfnts.len(), bytes)
|
||||
}
|
||||
|
||||
/// Glyph `gid` of TrueType font `data`: its outline and advance width, in ems (y up), or `None`
|
||||
/// when the font can't be read.
|
||||
pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> {
|
||||
let font = FontRef::new(data).ok()?;
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
if upem <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let id = GlyphId::new(gid);
|
||||
let mut pen = Pen(BezPath::new(), 1.0 / upem);
|
||||
if gid != 0
|
||||
&& let Some(g) = font.outline_glyphs().get(id)
|
||||
{
|
||||
// A glyph that can't be drawn draws nothing; its width still moves the next one.
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
|
||||
}
|
||||
let advance = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(id).map_or(0.0, f64::from) / upem;
|
||||
Some((pen.0, if advance.is_finite() { advance } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// A pen drawing an outline scaled from font units to ems.
|
||||
struct Pen(BezPath, f64);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((f64::from(x) * self.1, f64::from(y) * self.1));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((f64::from(x) * self.1, f64::from(y) * self.1));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
let s = self.1;
|
||||
self.0.quad_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(x) * s, f64::from(y) * s));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
let s = self.1;
|
||||
self.0.curve_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(cx1) * s, f64::from(cy1) * s), (f64::from(x) * s, f64::from(y) * s));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,27 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec
|
||||
|
||||
`simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner.
|
||||
|
||||
## Limits
|
||||
|
||||
Every boolean, Pathfinder operation, region map and normalization goes through one sweep. The `try_` functions
|
||||
(`try_boolean`, `try_normalize`, `try_unite_all`, …) refuse input it can't sweep with a [`PathOpsError`](src/lib.rs)
|
||||
rather than answering with part of the art missing. The others have no error to return: `boolean`, `normalize` and
|
||||
`area` answer an empty path or 0, and `unite_all` leaves a refused input out of the union, or answers an empty path
|
||||
when the inputs together are refused.
|
||||
|
||||
- `NonFinite`: a coordinate is NaN or infinite.
|
||||
- `Degenerate`: the coordinates are so large that the sweep's tolerance would pass one unit (`MAX_SWEEP_EPS`), so
|
||||
features thinner than it would collapse. A panic inside the sweep on rare near-degenerate input is caught and
|
||||
reported as `Degenerate` too (on native builds; on the web a panic aborts).
|
||||
- `OpenPath`: the sweep could not form closed contours from what it was given (open subpaths are closed before it).
|
||||
- `TooComplex`: more than [`MAX_SEGMENT_PAIRS`](src/budget.rs) pairs of segments have overlapping bounds. They
|
||||
are counted exactly before the sweep, in O(n log n). This bounds what the sweep is given, not how many times the
|
||||
segments cross: a compact path that crosses itself many times can stay under it and still take seconds.
|
||||
|
||||
Results are refitted within the precision (`DEFAULT_PRECISION`, unless `try_boolean`, `unite_all_with`,
|
||||
`try_unite_all_with` or `pathfinder_with` is given one) and contours thinner than it on average are dropped as slivers
|
||||
(see Booleans above).
|
||||
|
||||
## Caveats
|
||||
|
||||
- linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area};
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings};
|
||||
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
|
||||
|
||||
/// Turns 3D points into document points and shades faces for one view.
|
||||
@@ -403,7 +403,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
|
||||
if o.depth <= 0.0 {
|
||||
return plane_faces(path, rule, bounds, view, mesh);
|
||||
}
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, o.depth);
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let steps = steps(&o);
|
||||
@@ -461,7 +461,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
|
||||
/// [`plane`], or with `mesh` its face before projection instead of its facet.
|
||||
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
|
||||
check_bounds(bounds)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, 0.0);
|
||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
|
||||
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
|
||||
use super::rings::{MAX_SOLID_POINTS, rings, signed_area};
|
||||
use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings};
|
||||
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
|
||||
|
||||
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
|
||||
@@ -18,7 +18,7 @@ struct Puff {
|
||||
|
||||
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
|
||||
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
if rings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,175 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 }
|
||||
}
|
||||
|
||||
/// Contours thinner than this on average (twice their area over their perimeter) are dropped when
|
||||
/// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any
|
||||
/// art. Pathfinder's default precision would drop real hairlines too.
|
||||
pub(super) const DEBRIS: f64 = 1e-6;
|
||||
|
||||
/// The rings of a solid's profile, as [`rings`], with contours that meet merged first. Type often
|
||||
/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a
|
||||
/// contour's side from one point of it, so a contour that meets another, or itself, must be cut
|
||||
/// where they meet. Most art has no such contours, and the boolean costs more than the rest of the
|
||||
/// solid, so it runs only when [`edges_meet`] finds some. Its answer already runs holes against
|
||||
/// their outlines, so it is only turned as a whole ([`wound`]), never judged a ring at a time.
|
||||
///
|
||||
/// The `limit` is checked on the profile as drawn, before the boolean, and again on the merged
|
||||
/// outline, which can have more points (bars crossing in a grid trace every hole between them):
|
||||
/// past it the shape is too complex, since its contours as drawn cross and can't be built as they
|
||||
/// are. A profile the boolean refuses is built from its contours as drawn, as it was before
|
||||
/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice
|
||||
/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built.
|
||||
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let sampled = sample(path, size, limit)?;
|
||||
let closed = closed_straight(path);
|
||||
if !meets(&sampled, &closed) {
|
||||
return orient(sampled, rule);
|
||||
}
|
||||
match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) {
|
||||
Ok(merged) => sample(&merged, size, limit).map(wound),
|
||||
Err(_) => orient(sampled, rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must
|
||||
/// go through the boolean? Within twice the boolean's tolerance ([`touch`]): pathops first snaps
|
||||
/// anchors onto nearby heights (by less than the tolerance), then the sweep joins edges within it
|
||||
/// along the sweep line, so straight edges a little more than it apart can come out as one: at
|
||||
/// most sin θ + cos θ ≤ √2 times it, θ the edges' angle to the sweep line (about 1.4 at 45°,
|
||||
/// measured; the same when pathops turns the sweep a quarter turn, which it does for some art of
|
||||
/// 2,048 segments or more). Straight contours it might join are merged; those it leaves apart cost
|
||||
/// a boolean that builds the same shape. Curves are judged on their sampled outline (see
|
||||
/// [`sample`]), which can lie a little inside them (up to the sampling tolerance, or more for a
|
||||
/// long curve held to 32 pieces), so curves that touch or overlap by less than that can be judged
|
||||
/// apart and built as drawn.
|
||||
pub(super) fn meets(sampled: &[Vec<Point>], closed: &PathData) -> bool {
|
||||
edges_meet(sampled, 2.0 * touch(closed))
|
||||
}
|
||||
|
||||
/// `path` as the boolean should take it: each open subpath closed by a straight line, as [`sample`]
|
||||
/// and the renderer's fill close it (the boolean would close it along the end anchors' handles).
|
||||
/// Borrowed when nothing needs changing: every subpath closed, a single anchor (which the boolean
|
||||
/// skips), or open with no handle on the closing side of either end anchor (which the boolean
|
||||
/// already closes straight).
|
||||
pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> {
|
||||
// The closing segment (last anchor to first) as the boolean will draw it once closed; the
|
||||
// length test comes first, so a subpath of fewer than two anchors never reaches the index.
|
||||
let straight = |sp: &vectorcraft_geom::SubPath| sp.closed || sp.anchors.len() < 2 || sp.segment_is_line(sp.anchors.len() - 1);
|
||||
if path.subpaths.iter().all(straight) {
|
||||
return std::borrow::Cow::Borrowed(path);
|
||||
}
|
||||
let mut path = path.clone();
|
||||
for sp in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
if let Some(last) = sp.anchors.last_mut() {
|
||||
last.h_out = last.p;
|
||||
}
|
||||
if let Some(first) = sp.anchors.first_mut() {
|
||||
first.h_in = first.p;
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
std::borrow::Cow::Owned(path)
|
||||
}
|
||||
|
||||
/// The boolean's rings turned so the filled side is on each one's left. Its holes already run
|
||||
/// against their outlines, so only the direction of the whole is chosen: the one whose rings
|
||||
/// enclose positive area in all.
|
||||
pub(super) fn wound(mut rings: Vec<Vec<Point>>) -> Vec<Vec<Point>> {
|
||||
rings.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
if rings.iter().map(|r| signed_area(r)).sum::<f64>() < 0.0 {
|
||||
rings.iter_mut().for_each(|r| r.reverse());
|
||||
}
|
||||
rings
|
||||
}
|
||||
|
||||
/// The boolean's sweep tolerance for `path` as it is given to it (closed by [`closed_straight`]):
|
||||
/// 64 times the rounding of the largest coordinate it reads, every anchor and handle of each
|
||||
/// subpath of two anchors or more, and at least 1e-6 (pathops' `eps_for`). For a path [`sample`]
|
||||
/// accepts (every point finite).
|
||||
pub(super) fn touch(path: &PathData) -> f64 {
|
||||
let m = path
|
||||
.subpaths
|
||||
.iter()
|
||||
.filter(|sp| sp.anchors.len() >= 2)
|
||||
.flat_map(|sp| &sp.anchors)
|
||||
.flat_map(|a| [a.p, a.h_in, a.h_out])
|
||||
.fold(0.0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()));
|
||||
(m * f64::EPSILON * 64.0).max(1e-6)
|
||||
}
|
||||
|
||||
/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with
|
||||
/// its own budget, decides.
|
||||
const MAX_EDGE_PAIRS: usize = 1 << 22;
|
||||
|
||||
/// Do any two edges of `rings` meet, within `touch` (at that distance or closer): a contour crossing, touching or running
|
||||
/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one
|
||||
/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose
|
||||
/// bounds overlap across x are compared.
|
||||
pub(super) fn edges_meet(rings: &[Vec<Point>], touch: f64) -> bool {
|
||||
struct Edge {
|
||||
ring: usize,
|
||||
index: usize,
|
||||
a: Point,
|
||||
b: Point,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
}
|
||||
let mut edges: Vec<Edge> = rings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ring, r)| {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge {
|
||||
ring,
|
||||
index,
|
||||
a: *a,
|
||||
b: *b,
|
||||
x0: a.x.min(b.x),
|
||||
x1: a.x.max(b.x),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|e, f| e.x0.total_cmp(&f.x0));
|
||||
let len = |ring: usize| rings.get(ring).map_or(0, Vec::len);
|
||||
let mut pairs = 0usize;
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
for f in edges.iter().skip(i + 1) {
|
||||
if f.x0 > e.x1 + touch {
|
||||
break;
|
||||
}
|
||||
pairs += 1;
|
||||
if pairs > MAX_EDGE_PAIRS {
|
||||
return true;
|
||||
}
|
||||
if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + touch || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + touch {
|
||||
continue;
|
||||
}
|
||||
let n = len(e.ring);
|
||||
let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index);
|
||||
if neighbours {
|
||||
// They share a corner; they meet only if one runs back along the other.
|
||||
let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) };
|
||||
if super::build::segment_distance(q, shared, p) <= touch || super::build::segment_distance(p, shared, q) <= touch {
|
||||
return true;
|
||||
}
|
||||
} else if segments_meet(e.a, e.b, f.a, f.b, touch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Do segments `a`–`b` and `c`–`d` cross, or come within `touch` of each other?
|
||||
fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool {
|
||||
let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
||||
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
use super::build::segment_distance as dist;
|
||||
dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch
|
||||
}
|
||||
|
||||
/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer
|
||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||
@@ -28,11 +197,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||
/// if nesting alone decided (#1724).
|
||||
pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
orient(sample(path, size, limit)?, rule)
|
||||
}
|
||||
|
||||
/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as
|
||||
/// its subpath does.
|
||||
pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let tolerance = (size / 2000.0).max(0.05);
|
||||
let mut count = 0;
|
||||
let mut out = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
|
||||
// A subpath of one anchor draws nothing, closed or not (the renderer and the boolean skip it).
|
||||
for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 };
|
||||
if segments > limit {
|
||||
return Err("The shape is too complex for a 3D effect".into());
|
||||
}
|
||||
@@ -58,10 +234,21 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
|
||||
ring.pop();
|
||||
}
|
||||
if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 {
|
||||
// Rings with no area are kept here: a contour that crosses itself into lobes that cancel
|
||||
// (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat
|
||||
// ring, every point on one line, fills nothing under either rule and is left out now, so
|
||||
// that it doesn't send the profile through the boolean.
|
||||
if ring.len() >= 3 && !flat(&ring) {
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero
|
||||
/// `rule` without the rings that are no edge of the shape, as [`rings`] describes.
|
||||
fn orient(mut out: Vec<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, String> {
|
||||
out.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
// Which rings hold which: each ring against the others, its edges walked only where its
|
||||
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair
|
||||
// whose bounds miss the point costs a step too: many small rings are a square of pairs).
|
||||
@@ -118,6 +305,14 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
/// the others, so many rings of many points each would otherwise take billions of steps.
|
||||
const MAX_CONTAINMENT_EDGES: usize = 50_000_000;
|
||||
|
||||
/// Does every point of `ring` lie on one line (within 1e-9, as [`sample`] merges points)?
|
||||
fn flat(ring: &[Point]) -> bool {
|
||||
let Some(&a) = ring.first() else { return true };
|
||||
let Some(&b) = ring.iter().max_by(|p, q| a.distance(**p).total_cmp(&a.distance(**q))) else { return true };
|
||||
let len = a.distance(b);
|
||||
len <= 1e-9 || ring.iter().all(|p| ((b - a).cross(*p - a) / len).abs() <= 1e-9)
|
||||
}
|
||||
|
||||
pub(super) fn signed_area(r: &[Point]) -> f64 {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
@@ -754,3 +754,407 @@ fn many_small_rings_far_apart_are_too_complex() {
|
||||
let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err();
|
||||
assert!(e.contains("too complex"), "{e}");
|
||||
}
|
||||
|
||||
/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across
|
||||
/// the arm). Each contour's side was told from its first point, so one starting inside another
|
||||
/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The
|
||||
/// outline is the union the art fills, under either rule.
|
||||
#[test]
|
||||
fn crossing_contours_make_one_outline() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// The stem, an arm starting inside it, and a leg starting inside the arm.
|
||||
let k = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 20.0, 100.0)),
|
||||
rect(Rect::new(10.0, 40.0, 60.0, 60.0)),
|
||||
rect(Rect::new(50.0, 50.0, 70.0, 100.0)),
|
||||
]);
|
||||
let b = k.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
let filled = vectorcraft_pathops::area(&k, rule);
|
||||
assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate");
|
||||
let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows");
|
||||
assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap");
|
||||
let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap();
|
||||
assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k");
|
||||
assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed));
|
||||
}
|
||||
// The filled area is the union's: the stem, the arm past it and the leg past the arm.
|
||||
assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6));
|
||||
}
|
||||
|
||||
/// Resolving overlaps keeps every contour that fills area, however narrow: a hairline beside a
|
||||
/// large shape is art. What a boolean leaves where two curves coincide is still dropped.
|
||||
#[test]
|
||||
fn resolving_overlaps_keeps_narrow_contours() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// A hairline beside a square, and two bars crossing elsewhere so the profile is resolved.
|
||||
let hairline = Rect::new(120.0, 0.0, 120.005, 50.0);
|
||||
let p = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
rect(hairline),
|
||||
rect(Rect::new(140.0, 40.0, 200.0, 60.0)),
|
||||
rect(Rect::new(160.0, 20.0, 180.0, 80.0)),
|
||||
]);
|
||||
let b = p.bounds().unwrap();
|
||||
let filled = |rule: FillRule| {
|
||||
let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 };
|
||||
100.0 * 100.0 + hairline.width() * hairline.height() + bars
|
||||
};
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies.
|
||||
let circle = |cx: f64, cy: f64, r: f64| {
|
||||
let (mut b, k) = (BezPath::new(), 0.5523 * r);
|
||||
b.move_to((cx + r, cy));
|
||||
b.curve_to((cx + r, cy + k), (cx + k, cy + r), (cx, cy + r));
|
||||
b.curve_to((cx - k, cy + r), (cx - r, cy + k), (cx - r, cy));
|
||||
b.curve_to((cx - r, cy - k), (cx - k, cy - r), (cx, cy - r));
|
||||
b.curve_to((cx + k, cy - r), (cx + r, cy - k), (cx + r, cy));
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
for dx in [0.0, 1e-7] {
|
||||
let mut twice = circle(50.0, 50.0, 40.0);
|
||||
twice.extend(circle(50.0 + dx, 50.0, 40.0).iter());
|
||||
let p = PathData::from_bezpath(&twice);
|
||||
let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{dx}");
|
||||
assert_eq!(f[0].contours.len(), 1, "{dx}");
|
||||
let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0));
|
||||
let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap());
|
||||
assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along
|
||||
/// another or folding back on itself. Separate and nested contours are taken as they are.
|
||||
#[test]
|
||||
fn contours_meet_when_they_cross_touch_or_fold_back() {
|
||||
let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>();
|
||||
let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]);
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)], 1e-6), "apart");
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)], 1e-6), "nested, as an o's hole");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], 1e-6), "crossing");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], 1e-6), "sharing an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)], 1e-6), "along part of an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)], 1e-6), "at a corner");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)], 1e-6), "within the boolean's snapping");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])], 1e-6), "a bowtie crossing itself");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])], 1e-6), "folding back on itself");
|
||||
// Squares sharing an edge inflate as one puff, not two with a seam between them.
|
||||
let shared = PathData::new(
|
||||
[Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)]
|
||||
.iter()
|
||||
.map(|r| {
|
||||
vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)],
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let whole = PathData::from_bezpath(&{
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.line_to((100.0, 0.0));
|
||||
b.line_to((100.0, 50.0));
|
||||
b.line_to((0.0, 50.0));
|
||||
b.close_path();
|
||||
b
|
||||
});
|
||||
let b = whole.bounds().unwrap();
|
||||
let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len();
|
||||
assert_eq!(puff(&shared), puff(&whole));
|
||||
}
|
||||
|
||||
/// Flat contours fill nothing and build nothing, crossing or not; an invalid coordinate is refused,
|
||||
/// as it was; and a profile the boolean refuses is built from its contours as drawn.
|
||||
#[test]
|
||||
fn flat_invalid_and_refused_profiles() {
|
||||
let flat =
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]);
|
||||
let crossing_flat = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true),
|
||||
]);
|
||||
let b = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
for p in [&flat, &crossing_flat] {
|
||||
assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
}
|
||||
let mut bad = crossing_flat.clone();
|
||||
bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0);
|
||||
assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates");
|
||||
// A star of 3,001 points, each joined to the one 1,500 on: every edge spans the middle, so
|
||||
// past pathops' budget of segment pairs the boolean refuses it (`TooComplex`) without sweeping,
|
||||
// and the profile is turned as drawn.
|
||||
let n = 3001;
|
||||
let star: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * ((i * 1500) % n) as f64 / n as f64;
|
||||
Point::new(100.0 + 100.0 * a.cos(), 100.0 + 100.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let star = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&star, true)]);
|
||||
assert_eq!(
|
||||
vectorcraft_pathops::try_unite_all_with(&[(&star, FillRule::NonZero)], rings::DEBRIS),
|
||||
Err(vectorcraft_pathops::PathOpsError::TooComplex)
|
||||
);
|
||||
let b = star.bounds().unwrap();
|
||||
let drawn: f64 =
|
||||
rings::rings(&star, FillRule::NonZero, b.width().max(b.height()), usize::MAX).unwrap().iter().map(|r| rings::signed_area(r)).sum();
|
||||
assert!(drawn > 0.0);
|
||||
assert!(close(area(&plane(&star, FillRule::NonZero, b, front()).unwrap()), drawn, 1e-6), "built from its contours as drawn");
|
||||
}
|
||||
|
||||
/// A contour that crosses itself into lobes that cancel has no area of its own, yet fills both
|
||||
/// lobes; contours that cancel each other fill nothing. Both go through the boolean.
|
||||
#[test]
|
||||
fn self_crossing_and_cancelling_profiles_are_resolved() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
let bowtie = PathData::new(vec![poly(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]);
|
||||
let b = bowtie.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&bowtie, rule, b, front()).unwrap()), 50.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&bowtie, rule, b, Extrude::default(), front()).unwrap()), 50.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
assert!(inflate(&bowtie, rule, b, Inflate::default(), front()).is_ok_and(|f| close(area(&f), 50.0, 1e-6)), "{rule:?}: Inflate");
|
||||
}
|
||||
let square = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
|
||||
let twice = PathData::new(vec![square.clone(), square]);
|
||||
let b = twice.bounds().unwrap();
|
||||
assert_eq!(area(&plane(&twice, FillRule::EvenOdd, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(&twice, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
assert!(inflate(&twice, FillRule::EvenOdd, b, Inflate::default(), front()).is_ok_and(|f| area(&f) == 0.0));
|
||||
}
|
||||
|
||||
/// The boolean's answer often starts a contour where it touches another. Its rings are turned as
|
||||
/// a whole, never judged from one point each, so touching parts are kept and holes stay open.
|
||||
#[test]
|
||||
fn touching_contours_keep_their_parts_and_holes() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
// A triangle whose tip rests on a square's edge.
|
||||
let tip = PathData::new(vec![poly(&[(0.0, 10.0), (10.0, 10.0), (10.0, 20.0), (0.0, 20.0)]), poly(&[(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)])]);
|
||||
let b = tip.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&tip, rule, b, front()).unwrap()), 150.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&tip, rule, b, Extrude::default(), front()).unwrap()), 150.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// An even-odd triangular hole touching its square's edge.
|
||||
let hole =
|
||||
PathData::new(vec![poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), poly(&[(0.0, 50.0), (60.0, 0.0 + 20.0), (60.0, 80.0)])]);
|
||||
let b = hole.bounds().unwrap();
|
||||
assert!(close(area(&plane(&hole, FillRule::EvenOdd, b, front()).unwrap()), 10000.0 - 1800.0, 1e-6));
|
||||
}
|
||||
|
||||
/// An open subpath is closed by a straight line, whichever way the profile goes: the same as the
|
||||
/// renderer's fill, whether or not other contours meet.
|
||||
#[test]
|
||||
fn an_open_subpath_closes_straight_whether_or_not_contours_meet() {
|
||||
let open = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(0.0, -50.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor::corner(Point::new(50.0, 100.0)),
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(100.0, 0.0),
|
||||
h_in: Point::new(100.0, 0.0),
|
||||
h_out: Point::new(100.0, -50.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let alone = PathData::new(vec![open.clone()]);
|
||||
let with_bars = PathData::new(vec![open, rect(200.0, 40.0, 260.0, 60.0), rect(220.0, 20.0, 240.0, 80.0)]);
|
||||
let bars = 60.0 * 20.0 + 20.0 * 40.0;
|
||||
assert!(close(area(&plane(&alone, FillRule::NonZero, alone.bounds().unwrap(), front()).unwrap()), 5000.0, 1e-6));
|
||||
assert!(close(area(&plane(&with_bars, FillRule::NonZero, with_bars.bounds().unwrap(), front()).unwrap()), 5000.0 + bars, 1e-6));
|
||||
}
|
||||
|
||||
/// A flat contour beside separate ones fills nothing and doesn't send the profile through the
|
||||
/// boolean; contours far from the origin meet within the boolean's wider snapping; and a merged
|
||||
/// outline past the point limit is too complex.
|
||||
#[test]
|
||||
fn flat_far_and_too_large_merges() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect();
|
||||
let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false);
|
||||
let with_line = PathData::new(squares.iter().cloned().chain([line]).collect());
|
||||
assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line)));
|
||||
assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6));
|
||||
// Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the
|
||||
// test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas.
|
||||
let far = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.35e-6, 0.0, 9.9e7 + 20.0, 10.0)]);
|
||||
let cap = plane(&far, FillRule::NonZero, far.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost");
|
||||
// Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6.
|
||||
let lobe = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1000.0),
|
||||
h_in: Point::new(0.0, 1000.0),
|
||||
h_out: Point::new(1.3e8, 1000.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1100.0),
|
||||
h_in: Point::new(1.3e8, 1100.0),
|
||||
h_out: Point::new(0.0, 1100.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
let wide = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.6e-6, 0.0, 9.9e7 + 20.0, 10.0), lobe]);
|
||||
let b = wide.bounds().unwrap();
|
||||
let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap();
|
||||
assert_eq!(joined.iter().map(|f| f.contours.len()).sum::<usize>(), 2, "the squares joined, the lobe apart");
|
||||
// 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced:
|
||||
// past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so
|
||||
// they can't be built as they are), and Rotate's 65,536 takes it.
|
||||
let mut bars = vec![];
|
||||
for i in 0..24 {
|
||||
let x = i as f64 * 10.0;
|
||||
bars.push(rect(x, 0.0, x + 4.0, 240.0));
|
||||
bars.push(rect(0.0, x, 240.0, x + 4.0));
|
||||
}
|
||||
let grid = PathData::new(bars);
|
||||
let b = grid.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile goes through the boolean when its contours come within twice the boolean's tolerance
|
||||
/// of the path it is given: pathops snaps anchors onto nearby heights before sweeping, then the
|
||||
/// sweep joins edges within its tolerance, so contours a little more than it apart can come out
|
||||
/// as one.
|
||||
#[test]
|
||||
fn meets_within_twice_the_boolean_tolerance() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p));
|
||||
// Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on
|
||||
// the page (greater y) than the first's: snapped onto the first's heights, they are joined.
|
||||
let d = 0.99e-6;
|
||||
let snapped = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + d, d), Point::new(10.0 + d, 10.0 + d), Point::new(d, 10.0 + d)], true),
|
||||
]);
|
||||
assert!(meets(&snapped));
|
||||
let cap = plane(&snapped, FillRule::NonZero, snapped.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "joined, not lost");
|
||||
// At the same heights the boolean leaves the triangles apart, and the shape it builds is the
|
||||
// one drawn: both triangles, two outlines.
|
||||
let tri = |g: f64| {
|
||||
PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, 0.0), Point::new(10.0 + g, 10.0), Point::new(g, 10.0)], true),
|
||||
])
|
||||
};
|
||||
let apart = tri(1.4e-6 * std::f64::consts::SQRT_2);
|
||||
assert!(meets(&apart));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&apart, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
let cap = plane(&apart, FillRule::NonZero, apart.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 2);
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "both kept");
|
||||
// Twice the tolerance, not less: 45° edges 1.9e-6 apart still meet.
|
||||
assert!(meets(&tri(1.9e-6 * std::f64::consts::SQRT_2)));
|
||||
// And pathops joins nothing past it: the worst case, 2.05e-6 apart as `meets` measures them,
|
||||
// with the second triangle's corners just under the tolerance lower so that the snap brings it
|
||||
// closest, stays two outlines.
|
||||
let lift = 0.999e-6;
|
||||
let g = 2.05e-6 * std::f64::consts::SQRT_2 - lift;
|
||||
let past = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, lift), Point::new(10.0 + g, 10.0 + lift), Point::new(g, 10.0 + lift)], true),
|
||||
]);
|
||||
assert!(!meets(&past));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&past, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
// Near the origin, contours a few millionths apart stay apart and skip the boolean.
|
||||
assert!(!meets(&PathData::new(vec![rect(0.0, 0.0, 10.0, 10.0), rect(10.0 + 2.2e-6, 0.0, 20.0, 10.0)])));
|
||||
// What the boolean is given decides the tolerance: an open subpath's end handles (closed
|
||||
// straight) and a one-anchor subpath (skipped) don't widen it.
|
||||
let ends = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(1e9, 0.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(10.0, 0.0),
|
||||
h_in: Point::new(10.0, 0.0),
|
||||
h_out: Point::new(1e9, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true };
|
||||
let quiet = PathData::new(vec![ends, speck]);
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6);
|
||||
// A closing-side handle at either end alone is enough to close the subpath straight.
|
||||
for kept in [0, 1] {
|
||||
let mut one = quiet.subpaths[0].clone();
|
||||
one.anchors[0].h_in = if kept == 0 { Point::new(1e9, 0.0) } else { one.anchors[0].p };
|
||||
one.anchors[1].h_out = if kept == 1 { Point::new(1e9, 0.0) } else { one.anchors[1].p };
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&PathData::new(vec![one]))), 1e-6, "handle kept at anchor {kept}");
|
||||
}
|
||||
// Beside two squares 3e-6 apart near the origin (farther apart than twice 1e-6), they don't
|
||||
// send the profile through the boolean; read unclosed, the end handles would.
|
||||
let mut quiet_squares = quiet.clone();
|
||||
quiet_squares.subpaths.extend([rect(0.0, 100.0, 10.0, 110.0), rect(10.0 + 3e-6, 100.0, 20.0, 110.0)]);
|
||||
assert!(!meets(&quiet_squares));
|
||||
}
|
||||
|
||||
/// The boolean's rings are turned as a whole so the filled side is on their left: holes stay
|
||||
/// against their outlines, and a profile wound the other way round is turned over.
|
||||
#[test]
|
||||
fn the_merged_rings_are_turned_as_a_whole() {
|
||||
let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)];
|
||||
let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)];
|
||||
let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]);
|
||||
assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0);
|
||||
let kept = rings::wound(vec![outline.clone(), hole.clone()]);
|
||||
assert_eq!(kept, vec![outline, hole]);
|
||||
}
|
||||
|
||||
/// A subpath of one anchor draws nothing, so it builds nothing, beside other art or not.
|
||||
#[test]
|
||||
fn a_one_anchor_subpath_builds_nothing() {
|
||||
let loop_ = vectorcraft_geom::SubPath {
|
||||
anchors: vec![vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(-20.0, 30.0),
|
||||
h_out: Point::new(20.0, 30.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
}],
|
||||
closed: true,
|
||||
};
|
||||
let p = PathData::new(vec![loop_]);
|
||||
let b = Rect::new(-20.0, 0.0, 20.0, 30.0);
|
||||
assert_eq!(area(&plane(&p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -179,7 +179,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. |
|
||||
| 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS and `.ai` editing data: opacity mask art without its own paint takes the paint before its dictionary; a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -268,7 +268,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
|
||||
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
|
||||
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
|
||||
@@ -306,6 +306,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. |
|
||||
| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |
|
||||
|
||||
+12
-1
@@ -17,6 +17,14 @@ Preview changes are temporary until OK; Cancel restores the document; OK is one
|
||||
3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying
|
||||
another.
|
||||
|
||||
Each solid follows its shape as it is filled. Separate and nested contours are built as drawn. Contours that cross,
|
||||
touch or run along each other or themselves, as type often draws a glyph, are first merged into one outline by
|
||||
[`vectorcraft-pathops`](../crates/pathops/README.md), keeping every contour but the numerical debris a boolean leaves.
|
||||
Whether contours meet is judged on the outline as it is followed (see the curve tolerance below), so curves that
|
||||
overlap by less than that are built as drawn. All three effects share pathops'
|
||||
[limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be.
|
||||
An open subpath is closed by a straight line either way.
|
||||
|
||||
## 3D and Materials panel
|
||||
|
||||
**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the
|
||||
@@ -228,7 +236,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
effect per object.
|
||||
- Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points
|
||||
and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is
|
||||
counted on its own), Rotate (which only turns the outline) 65,536.
|
||||
counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,
|
||||
when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace
|
||||
every hole between them); past it, the shape is too complex for the effect.
|
||||
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
|
||||
unbuilt items.
|
||||
|
||||
@@ -238,6 +248,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance]
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. |
|
||||
| 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). |
|
||||
| 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. |
|
||||
| 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. |
|
||||
|
||||
Reference in New Issue
Block a user