mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Merge main into type-3d-redraw
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
+4
-4
@@ -15,7 +15,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -83,7 +83,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 4–8 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 88% | — |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -131,8 +131,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
5. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2).
|
||||
|
||||
## Progress log
|
||||
|
||||
@@ -226,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
|
||||
|
||||
@@ -125,7 +125,7 @@ const BACKGROUND: FormatOption = FormatOption {
|
||||
name: "background",
|
||||
ty: "string",
|
||||
default: "\"transparent\"",
|
||||
description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)",
|
||||
description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent); drawn under the finished art, so blend modes do not read it",
|
||||
};
|
||||
|
||||
const ANTI_ALIAS: FormatOption = FormatOption {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and
|
||||
//! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour
|
||||
//! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by
|
||||
//! [`composite`]), over white (or over nothing with Preserve Alpha); in CMYK documents their inks
|
||||
//! [`composite`]), with white under the finished art (or nothing with Preserve Alpha); in CMYK documents their inks
|
||||
//! composite, plane by plane ([`cmyk_planes`]), and the colour is CMYK. Each region becomes a path
|
||||
//! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster
|
||||
//! effects reach there is no single colour: those regions are rendered into one image per group,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::analysis::{Scratch, outlined_text, overlapping, reach};
|
||||
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE};
|
||||
use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE};
|
||||
use super::options::FlattenOptions;
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks,
|
||||
MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate,
|
||||
shape_node, shapes, unique_key,
|
||||
MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, composite, effects, isolated_doc, outline_strokes_under, planes_cmyk, po,
|
||||
separate, shape_node, shapes, unique_key,
|
||||
};
|
||||
|
||||
// ---------- plan ----------
|
||||
@@ -235,18 +235,14 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
|
||||
if alone[3] < 0.5 / 255.0 {
|
||||
return None;
|
||||
}
|
||||
let start = if o.preserve_alpha { CLEAR } else { WHITE };
|
||||
let px = if o.preserve_alpha {
|
||||
alone
|
||||
} else {
|
||||
c.top = None;
|
||||
c.eval(elems, start)
|
||||
};
|
||||
// The white goes under the finished art, as under the renderer's: blend modes read only the art.
|
||||
let on_page = |p: Px| if o.preserve_alpha { p } else { composite(BlendMode::Normal, WHITE, p) };
|
||||
let px = on_page(alone);
|
||||
let rgb = [px[0], px[1], px[2]];
|
||||
// The K plane composites the same way.
|
||||
let k = cmyk.then(|| {
|
||||
c.plane = 1;
|
||||
c.eval(elems, start)[0]
|
||||
on_page(c.eval(elems, CLEAR))[0]
|
||||
});
|
||||
// A paint showing as it is keeps its colour (spot, process, swatch) and overprint.
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3);
|
||||
@@ -266,7 +262,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
|
||||
}
|
||||
|
||||
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
|
||||
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
|
||||
/// again, or over nothing with Preserve Alpha. The image and
|
||||
/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink
|
||||
/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs.
|
||||
/// Fails when the image can't be encoded.
|
||||
|
||||
@@ -447,6 +447,27 @@ fn export_selection_keeps_the_effects_in_the_crop() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An Outer Glow at its default Screen mode on the bare page is in a PNG on white and in a JPEG,
|
||||
/// and in a transparent PNG (#6125).
|
||||
#[test]
|
||||
fn a_screen_outer_glow_is_in_raster_exports() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap();
|
||||
let mut pixel = |p: serde_json::Value| {
|
||||
let v = s.execute("document.export", &p).unwrap();
|
||||
let img = image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8();
|
||||
// 2 pt past the square's stroke, on the right.
|
||||
*img.get_pixel(122, 110)
|
||||
};
|
||||
for p in [json!({"format": "png", "background": "white"}), json!({"format": "jpg"})] {
|
||||
let px = pixel(p.clone());
|
||||
assert!(px[0] > 235 && px[1] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}");
|
||||
}
|
||||
let px = pixel(json!({"format": "png", "background": "transparent"}));
|
||||
assert!(px[3] > 0 && px[0] > 235 && px[1] > 235 && px[2] < 40, "the glow is yellow in the transparent image: {px:?}");
|
||||
}
|
||||
|
||||
/// A group's own drop shadow is inside the crop of the group's selection export too.
|
||||
#[test]
|
||||
fn export_selection_keeps_a_groups_shadow_in_the_crop() {
|
||||
|
||||
@@ -251,11 +251,16 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() {
|
||||
let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap();
|
||||
for g in &lib.styles {
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
// Art under the rectangle: a blend mode shows only against art, not the bare page.
|
||||
let under = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [under.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [under.0]}));
|
||||
let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0);
|
||||
run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true}));
|
||||
assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name);
|
||||
let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true);
|
||||
let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count();
|
||||
let bare = img.pixel(0, 0);
|
||||
let painted = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) != bare).count();
|
||||
assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::import_shading::{
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
mod actual_text;
|
||||
mod builder;
|
||||
mod cid;
|
||||
mod device;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Bounded ownership of authored replacement text (ISO 32000-1, 14.9.4).
|
||||
|
||||
use super::state::Builder;
|
||||
use super::{Affine, Look, Placement, Shape, TextLine, Upright};
|
||||
use crate::import_text::TextContent;
|
||||
|
||||
/// Only one replacement is retained: the outermost owner, even after it is consumed.
|
||||
/// Depth is counted separately so ignored deep tags still unwind to the right owner.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ActualText {
|
||||
depth: usize,
|
||||
owner: Option<usize>,
|
||||
pending: Option<String>,
|
||||
}
|
||||
|
||||
impl ActualText {
|
||||
pub(super) fn begin(&mut self) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(super) fn end(&mut self) {
|
||||
if self.owner == Some(self.depth) {
|
||||
self.owner = None;
|
||||
self.pending = None;
|
||||
}
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub(super) fn active(&self) -> bool {
|
||||
self.owner.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, bytes: &[u8]) -> bool {
|
||||
if self.active() {
|
||||
return true;
|
||||
}
|
||||
// Bound before decoding: at most 64 KiB encoded / 192 KiB UTF-8, and no
|
||||
// allocation per nested tag. Oversized/deep replacements use ordinary mappings.
|
||||
if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 {
|
||||
return false;
|
||||
}
|
||||
self.owner = Some(self.depth);
|
||||
self.pending = Some(crate::import_scan::text_string(bytes));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Builder<'_> {
|
||||
/// The authored path never enters ordinary visual glyph reordering. The font was matched
|
||||
/// against the glyph's original Unicode, not against this replacement's characters.
|
||||
pub(super) fn actual_glyph(&mut self, o: &craft_pdf::interpret::font::OutlineGlyph, m: Affine, at: Placement, look: Look, opacity: f32) -> bool {
|
||||
let key = o.font_cache_key();
|
||||
self.flush_glyphs();
|
||||
// A fill and stroke callback for the same glyph consumes its text only once.
|
||||
if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let width = o.advance_width().filter(|w| w.is_finite());
|
||||
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
let top = width.filter(|w| w.abs() <= 1.0).map(|_| {
|
||||
let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(o.glyph_id().to_u32()));
|
||||
Upright { top: m * kurbo::Point::new(vx, 880.0) }
|
||||
});
|
||||
// Keep pending until placement succeeds, including across a line flush.
|
||||
let pending = self.actual.pending.take();
|
||||
let content = match pending.as_deref() {
|
||||
Some(text) if !text.is_empty() => TextContent::Actual(text),
|
||||
_ => TextContent::Pen,
|
||||
};
|
||||
let place = |line: &mut TextLine| match top {
|
||||
Some(top) => line.push_upright_content(&look, at, opacity, top, content),
|
||||
None => line.push_content(&look, at, opacity, advance, content),
|
||||
};
|
||||
if !self.text.as_mut().is_some_and(place) {
|
||||
self.flush_text();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
if !place(&mut line) {
|
||||
self.actual.pending = pending;
|
||||
return false;
|
||||
}
|
||||
self.text = Some(line);
|
||||
}
|
||||
let mut ink = o.outline();
|
||||
if let Some(line) = &mut self.text
|
||||
&& !ink.elements().is_empty()
|
||||
{
|
||||
ink.apply_affine(m);
|
||||
line.ink.push(ink.bounding_box());
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ impl<'p> Builder<'p> {
|
||||
glyphs: None,
|
||||
text: None,
|
||||
text_as,
|
||||
actual: Default::default(),
|
||||
images: HashMap::new(),
|
||||
image_keys: HashMap::new(),
|
||||
cmyk_keys: HashSet::new(),
|
||||
@@ -102,6 +103,7 @@ impl<'p> Builder<'p> {
|
||||
self.mask = None;
|
||||
self.masked.clear();
|
||||
self.marked.clear();
|
||||
self.actual = Default::default();
|
||||
(self.scan, self.tag_at, self.aligned, self.paint_at) = (scan, 0, true, 0);
|
||||
if self.scan.overprints && self.scan.zero_mode {
|
||||
self.warn(OVERPRINT_ZERO_MODE);
|
||||
@@ -749,6 +751,7 @@ impl<'p> Builder<'p> {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
}
|
||||
let authored = self.actual.active();
|
||||
let key = o.font_cache_key();
|
||||
let glyph = o.glyph_id().to_u32();
|
||||
let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) {
|
||||
@@ -773,6 +776,7 @@ impl<'p> Builder<'p> {
|
||||
let text: String = match unicode {
|
||||
Some(BfString::Char(c)) => c.to_string(),
|
||||
Some(BfString::String(s)) => s,
|
||||
None if authored => String::new(),
|
||||
None => {
|
||||
// Invisible text isn't kept as outlines: the caller says it was left out.
|
||||
if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) {
|
||||
@@ -785,7 +789,7 @@ impl<'p> Builder<'p> {
|
||||
// Adobe's CID tables map some ideographs to their look-alike Kangxi radicals.
|
||||
let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text };
|
||||
let Some(at) = Placement::of(m) else { return false };
|
||||
if text.is_empty() || text.chars().any(|c| c.is_control()) {
|
||||
if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) {
|
||||
return false;
|
||||
}
|
||||
let width = o.advance_width().filter(|w| w.is_finite());
|
||||
@@ -849,6 +853,9 @@ impl<'p> Builder<'p> {
|
||||
fill: if stroke.is_none() { paint } else { None },
|
||||
stroke,
|
||||
};
|
||||
if authored {
|
||||
return self.actual_glyph(o, m, at, look, opacity);
|
||||
}
|
||||
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
// A glyph set vertically is drawn from its origin, which the file puts the position
|
||||
// vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of
|
||||
|
||||
@@ -378,6 +378,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if self.nested > 0 {
|
||||
return;
|
||||
}
|
||||
self.actual.begin();
|
||||
let at = self.tag_at;
|
||||
self.tag_at += 1;
|
||||
let group = match self.scan.tags.get(at) {
|
||||
@@ -416,5 +417,12 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.flush();
|
||||
}
|
||||
self.marked.pop();
|
||||
self.actual.end();
|
||||
}
|
||||
|
||||
fn actual_text(&mut self, text: &[u8]) {
|
||||
if self.nested == 0 && self.text_as == crate::TextAs::Text && !self.actual.set(text) {
|
||||
self.warn("ActualText beyond the import size or nesting limit was left to glyph mappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ pub(super) struct Builder<'p> {
|
||||
pub(super) glyphs: Option<GlyphRun>,
|
||||
pub(super) text: Option<TextLine>,
|
||||
pub(super) text_as: TextAs,
|
||||
pub(super) actual: super::actual_text::ActualText,
|
||||
pub(super) images: HashMap<String, ImageBlob>,
|
||||
pub(super) image_keys: HashMap<u128, (String, u32, u32)>,
|
||||
/// The images kept with their CMYK samples ([`crate::import_image`]).
|
||||
|
||||
+138
-16
@@ -146,6 +146,27 @@ fn common_gap(gaps: &[Gap]) -> f64 {
|
||||
if *common > 0.5 { 0.0 } else { common.max(0.0) }
|
||||
}
|
||||
|
||||
/// A mapped glyph, authored logical text, or a glyph whose replacement was already placed.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TextContent<'a> {
|
||||
Mapped(&'a str),
|
||||
Actual(&'a str),
|
||||
Pen,
|
||||
}
|
||||
|
||||
impl<'a> TextContent<'a> {
|
||||
fn text(self) -> &'a str {
|
||||
match self {
|
||||
Self::Mapped(text) | Self::Actual(text) => text,
|
||||
Self::Pen => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn authored(self) -> bool {
|
||||
!matches!(self, Self::Mapped(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of type being gathered: runs of glyphs on one baseline.
|
||||
pub(crate) struct TextLine {
|
||||
/// The first glyph's placement.
|
||||
@@ -173,6 +194,10 @@ pub(crate) struct TextLine {
|
||||
/// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a
|
||||
/// glyph of several have none.
|
||||
slots: Vec<Option<Slot>>,
|
||||
/// Per character: authored logical order must survive visual-to-logical reconstruction.
|
||||
authored: Vec<bool>,
|
||||
/// Fill callbacks in an authored span, for the reader's later stroke pass.
|
||||
actual_fills: HashMap<(u128, u64, u64), Option<usize>>,
|
||||
/// The first glyph was set vertically ([`Upright`]).
|
||||
upright: Option<Upright>,
|
||||
/// The glyphs make a column (vertical type), each below the last.
|
||||
@@ -232,6 +257,8 @@ impl TextLine {
|
||||
runs: vec![],
|
||||
text_bytes: 0,
|
||||
slots: vec![],
|
||||
authored: vec![],
|
||||
actual_fills: HashMap::new(),
|
||||
upright: None,
|
||||
column: false,
|
||||
ink: vec![],
|
||||
@@ -251,10 +278,24 @@ impl TextLine {
|
||||
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
|
||||
/// another line.
|
||||
pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool {
|
||||
self.push_upright_content(look, at, opacity, upright, TextContent::Mapped(text))
|
||||
}
|
||||
|
||||
pub fn push_upright_content(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, content: TextContent<'_>) -> bool {
|
||||
let text = content.text();
|
||||
if !text.is_empty() && self.authored.is_empty() && self.upright.is_some() {
|
||||
// Suppressed vertical glyphs likewise provide no text anchor.
|
||||
self.at = at;
|
||||
self.opacity = opacity;
|
||||
self.runs.clear();
|
||||
self.column = false;
|
||||
}
|
||||
let down = -self.at.up();
|
||||
let size = self.at.size.max(at.size);
|
||||
let Some((last, _, run)) = self.runs.last_mut() else {
|
||||
self.runs.push((look.clone(), 0.0, text.to_string()));
|
||||
self.record_actual_fill(look, at, content);
|
||||
self.authored.extend(text.chars().map(|_| content.authored()));
|
||||
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
|
||||
return true;
|
||||
};
|
||||
@@ -276,14 +317,20 @@ impl TextLine {
|
||||
self.column = true;
|
||||
// Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left
|
||||
// out (the tracking set in `finish` keeps narrower gaps).
|
||||
if step > size * 0.9 && !run.ends_with(is_space) && !text.starts_with(is_space) {
|
||||
if !matches!(content, TextContent::Pen) && step > size * 0.9 && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) {
|
||||
run.push(' ');
|
||||
self.authored.push(false);
|
||||
}
|
||||
if last.takes(look) {
|
||||
if last.takes(look)
|
||||
&& (matches!(content, TextContent::Pen)
|
||||
|| (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false)))
|
||||
{
|
||||
run.push_str(text);
|
||||
} else {
|
||||
self.runs.push((look.clone(), 0.0, text.to_string()));
|
||||
}
|
||||
self.record_actual_fill(look, at, content);
|
||||
self.authored.extend(text.chars().map(|_| content.authored()));
|
||||
self.next = at.origin + down * at.size;
|
||||
self.last = at.origin;
|
||||
true
|
||||
@@ -292,10 +339,28 @@ impl TextLine {
|
||||
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
||||
/// it continues this line; `false`: it starts another.
|
||||
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
||||
self.push_content(look, at, opacity, advance, TextContent::Mapped(text))
|
||||
}
|
||||
|
||||
pub fn push_content(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, content: TextContent<'_>) -> bool {
|
||||
let text = content.text();
|
||||
let pen = matches!(content, TextContent::Pen);
|
||||
// A vertical line (even a single upright glyph) takes no horizontal glyphs.
|
||||
if self.column || self.upright.is_some() {
|
||||
return false;
|
||||
}
|
||||
if !pen && self.text_bytes == 0 && !self.baseline.is_empty() {
|
||||
// A line containing only suppressed glyphs has no text anchor yet. Its first
|
||||
// real character starts at its own origin, including after a line flush.
|
||||
self.at = at;
|
||||
self.opacity = opacity;
|
||||
self.next = at.origin;
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.last_size = at.size;
|
||||
self.baseline.clear();
|
||||
self.runs.clear();
|
||||
}
|
||||
let bytes = self.text_bytes;
|
||||
let glyph = self.baseline.len();
|
||||
let curved = self.curved();
|
||||
@@ -334,12 +399,12 @@ impl TextLine {
|
||||
if !on_line && !on_curve {
|
||||
return false;
|
||||
}
|
||||
if !run.ends_with(is_space) && !text.starts_with(is_space) {
|
||||
if !pen && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) {
|
||||
self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line });
|
||||
}
|
||||
// A space the file draws between two glyphs spans the gaps on both sides of it.
|
||||
let drawn = self.drawn_space.take();
|
||||
if on_line {
|
||||
let drawn = if pen { None } else { self.drawn_space.take() };
|
||||
if on_line && !pen {
|
||||
let mut chars = text.chars();
|
||||
if chars.next().is_some_and(is_space) && chars.next().is_none() && !run.ends_with(is_space) {
|
||||
self.drawn_space = Some((bytes, gap + advance));
|
||||
@@ -347,7 +412,10 @@ impl TextLine {
|
||||
self.drawn_spaces.push((b, before + gap));
|
||||
}
|
||||
}
|
||||
if last.takes(look) && (*last_shift - shift).abs() < 1e-3 {
|
||||
if last.takes(look)
|
||||
&& (*last_shift - shift).abs() < 1e-3
|
||||
&& (pen || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false)))
|
||||
{
|
||||
run.push_str(text);
|
||||
} else {
|
||||
self.runs.push((look.clone(), shift, text.to_string()));
|
||||
@@ -355,12 +423,18 @@ impl TextLine {
|
||||
} else {
|
||||
self.runs.push((look.clone(), 0.0, text.to_string()));
|
||||
}
|
||||
self.record_actual_fill(look, at, content);
|
||||
self.text_bytes = self.text_bytes.saturating_add(text.len());
|
||||
let one = text.chars().count() == 1;
|
||||
self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) }));
|
||||
self.slots.extend(text.chars().skip(1).map(|_| None));
|
||||
if !pen {
|
||||
let one = text.chars().count() == 1 && !content.authored();
|
||||
self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) }));
|
||||
self.slots.extend(text.chars().skip(1).map(|_| None));
|
||||
self.authored.extend(text.chars().map(|_| content.authored()));
|
||||
}
|
||||
// Pen-only glyphs retain a baseline but no character slot: later text still points
|
||||
// to its own glyph, even after several suppressed glyphs or a style change.
|
||||
self.next = at.origin + at.dir * advance;
|
||||
if !text.trim().is_empty() {
|
||||
if pen || !text.trim().is_empty() {
|
||||
self.visible = self.next;
|
||||
}
|
||||
self.last = at.origin;
|
||||
@@ -370,6 +444,25 @@ impl TextLine {
|
||||
true
|
||||
}
|
||||
|
||||
fn record_actual_fill(&mut self, look: &Look, at: Placement, content: TextContent<'_>) {
|
||||
if content.authored() && look.fill.is_some() {
|
||||
let run = matches!(content, TextContent::Actual(_)).then(|| self.runs.len().saturating_sub(1));
|
||||
self.actual_fills.insert((look.font, at.origin.x.to_bits(), at.origin.y.to_bits()), run);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader fills a whole Tj/TJ before replaying its glyphs as strokes. A replay
|
||||
/// must neither consume the replacement nor rewind the pen. Only the first glyph's
|
||||
/// style belongs to the replacement; suppressed glyphs have no text to restyle.
|
||||
pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
|
||||
let key = (font, at.origin.x.to_bits(), at.origin.y.to_bits());
|
||||
let Some(run) = self.actual_fills.remove(&key) else { return false };
|
||||
if let Some((look, ..)) = run.and_then(|i| self.runs.get_mut(i)) {
|
||||
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
|
||||
fn curved(&self) -> bool {
|
||||
self.last_dir.dot(self.at.dir) < 0.9986
|
||||
@@ -416,16 +509,27 @@ impl TextLine {
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
let path = if self.curved() { self.baseline_path() } else { None };
|
||||
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(.., t)| t.ends_with(' '));
|
||||
if let Some((.., last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
if !self.authored.last().copied().unwrap_or(false)
|
||||
&& let Some((.., last)) = self.runs.last_mut()
|
||||
{
|
||||
// Trim only ordinary trailing spaces, never authored replacement characters.
|
||||
let trim = last
|
||||
.chars()
|
||||
.rev()
|
||||
.zip(self.authored.iter().rev())
|
||||
.take_while(|(c, authored)| c.is_whitespace() && !**authored)
|
||||
.map(|(c, _)| c.len_utf8())
|
||||
.sum::<usize>();
|
||||
last.truncate(last.len().saturating_sub(trim));
|
||||
}
|
||||
self.runs.retain(|(.., t)| !t.is_empty());
|
||||
if self.runs.iter().all(|(.., t)| t.trim().is_empty()) {
|
||||
if self.runs.iter().all(|(.., t)| t.is_empty()) || (!self.authored.contains(&true) && self.runs.iter().all(|(.., t)| t.trim().is_empty())) {
|
||||
return None;
|
||||
}
|
||||
// The spaces trimmed off the end have no place either.
|
||||
let chars: usize = self.runs.iter().map(|(.., t)| t.chars().count()).sum();
|
||||
self.slots.truncate(chars);
|
||||
self.authored.truncate(chars);
|
||||
// The baseline most of the characters sit on is the line's: the others are shifted off it
|
||||
// (a line starting with a raised footnote mark keeps the baseline of its text).
|
||||
let mut on: Vec<(f64, usize)> = vec![];
|
||||
@@ -493,6 +597,7 @@ impl TextLine {
|
||||
let char_at = earlier + head.chars().count();
|
||||
if char_at <= self.slots.len() {
|
||||
self.slots.insert(char_at, None);
|
||||
self.authored.insert(char_at, false);
|
||||
}
|
||||
run.text.insert(at, ' ');
|
||||
spaces.push((gap.at, gap.width, gap.on_line));
|
||||
@@ -502,7 +607,7 @@ impl TextLine {
|
||||
gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width)));
|
||||
gaps.sort_by_key(|&(b, _)| b);
|
||||
if self.upright.is_none() {
|
||||
to_logical(&mut t);
|
||||
to_logical(&mut t, &self.authored);
|
||||
}
|
||||
let angle = self.at.dir.atan2();
|
||||
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
|
||||
@@ -1055,9 +1160,26 @@ fn is_style(s: &str) -> bool {
|
||||
|
||||
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
|
||||
/// back in logical order, with the paragraph direction that shows it as drawn.
|
||||
fn to_logical(t: &mut TextObject) {
|
||||
let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
|
||||
fn to_logical(t: &mut TextObject, authored: &[bool]) {
|
||||
let Some((mut order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
|
||||
let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect();
|
||||
if authored.contains(&true) {
|
||||
// Explicit spans are logical-order islands. Reconstruct each ordinary stretch on
|
||||
// either side independently; never reverse authored Arabic or its joiners again.
|
||||
order.clear();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let actual = authored.get(start).copied().unwrap_or(false);
|
||||
let end = (start + 1..chars.len()).find(|&i| authored.get(i).copied().unwrap_or(false) != actual).unwrap_or(chars.len());
|
||||
let text: String = chars.get(start..end).unwrap_or_default().iter().map(|(c, _)| c).collect();
|
||||
if !actual && let Some((local, _)) = vectorcraft_text::logical_order(&text) {
|
||||
order.extend(local.into_iter().map(|i| start + i));
|
||||
} else {
|
||||
order.extend(start..end);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len());
|
||||
for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) {
|
||||
match runs.last_mut() {
|
||||
|
||||
@@ -8,6 +8,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod actual_text;
|
||||
mod blend;
|
||||
mod charstroke;
|
||||
mod cmykblend;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Original, synthetic PDF content; no external fonts needed for semantic coverage.
|
||||
|
||||
use crate::{ImportOptions, ImportReport, TextAs, import_with_report};
|
||||
use vectorcraft_doc::{NodeKind, TextObject};
|
||||
|
||||
fn stream(entries: &str, content: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = format!("<< /Length {} {entries} >>\nstream\n", content.len()).into_bytes();
|
||||
bytes.extend_from_slice(content);
|
||||
bytes.extend_from_slice(b"\nendstream");
|
||||
bytes
|
||||
}
|
||||
|
||||
fn document(objects: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = vec![0];
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend(format!("{} 0 obj\n", i + 1).bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).bytes());
|
||||
for offset in offsets.iter().skip(1) {
|
||||
bytes.extend(format!("{offset:010} 00000 n \n").bytes());
|
||||
}
|
||||
bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pdf(pages: &[&str]) -> Vec<u8> {
|
||||
let kids = (0..pages.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", pages.len()).into_bytes(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
];
|
||||
for (i, content) in pages.iter().enumerate() {
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> /Properties << /Named << /ActualText (Named) >> >> >> /Contents {} 0 R >>", 5 + i * 2).into_bytes());
|
||||
objects.push(stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes()));
|
||||
}
|
||||
document(&objects)
|
||||
}
|
||||
|
||||
fn open(content: &str) -> ImportReport {
|
||||
import_with_report(&pdf(&[content]), &ImportOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
// Geometry assertions need the PDF and layout to use the same widths. Unembedded
|
||||
// Helvetica is not installed on every platform; missing-font layout fits the overall
|
||||
// line length, so removing characters changes even an ordinary suffix's position.
|
||||
fn open_positioned(content: &str) -> ImportReport {
|
||||
let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let face = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
|
||||
assert_eq!(face.file_data(), data);
|
||||
let widths = (32u8..=126)
|
||||
.map(|c| {
|
||||
let glyph = face.glyph_for(char::from(c));
|
||||
assert_ne!(glyph, 0);
|
||||
(face.advance(glyph) * 1000.0 / face.units_per_em()).to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"
|
||||
);
|
||||
let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec();
|
||||
let bytes = custom_pdf(content, font.as_bytes(), "", &[descriptor, stream("", data)]);
|
||||
import_with_report(&bytes, &ImportOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn texts(report: &ImportReport) -> Vec<TextObject> {
|
||||
let mut result = vec![];
|
||||
report.document.walk(|node| {
|
||||
if let NodeKind::Text(text) = &node.kind {
|
||||
result.push((**text).clone());
|
||||
}
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
fn plain(report: &ImportReport) -> String {
|
||||
texts(report).iter().map(TextObject::plain_text).collect()
|
||||
}
|
||||
|
||||
fn utf16(text: &str) -> String {
|
||||
format!("<FEFF{}>", text.encode_utf16().map(|u| format!("{u:04X}")).collect::<String>())
|
||||
}
|
||||
|
||||
fn character_origin(report: &ImportReport, character: char) -> kurbo::Point {
|
||||
for text in texts(report) {
|
||||
let plain = text.plain_text();
|
||||
if let Some(byte) = plain.find(character) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text);
|
||||
let glyph = layout.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
return text.xf * glyph.origin;
|
||||
}
|
||||
}
|
||||
panic!("missing {character}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_replaces_once_and_keeps_suffix_position() {
|
||||
let replaced = open_positioned("/Span << /ActualText (AB) >> BDC (xy) Tj EMC (C) Tj");
|
||||
let ordinary = open_positioned("(xyC) Tj");
|
||||
assert_eq!(plain(&replaced), "ABC");
|
||||
let delta = character_origin(&replaced, 'C') - character_origin(&ordinary, 'C');
|
||||
assert!(delta.hypot() < 0.05, "suffix moved: {delta:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_empty_and_nested_owners() {
|
||||
for (content, expected) in [
|
||||
("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (C) Tj", "PC"),
|
||||
("/Span << /ActualText (AB) >> BDC /Span << /ActualText (INNER) >> BDC (x) Tj EMC (y) Tj EMC (C) Tj", "ABC"),
|
||||
("/Span << /ActualText () >> BDC /Span << /ActualText (INNER) >> BDC (xy) Tj EMC EMC (C) Tj", "C"),
|
||||
("/Span BMC /Span << /ActualText (AB) >> BDC (xy) Tj EMC EMC (C) Tj", "ABC"),
|
||||
("/Span << /ActualText (A) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC (C) Tj", "ABC"),
|
||||
] {
|
||||
assert_eq!(plain(&open(content)), expected, "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_decodes_pdf_strings_and_preserves_authored_order() {
|
||||
for (encoded, expected) in [
|
||||
(utf16("ل\u{200d}ا\u{200c}ب"), "ل\u{200d}ا\u{200c}ب"),
|
||||
("<8093A0>".into(), "•fi€"),
|
||||
("<EFBBBF4162>".into(), "Ab"),
|
||||
("( A )".into(), " A "),
|
||||
("( )".into(), " "),
|
||||
(utf16("A\nB"), "A\nB"),
|
||||
] {
|
||||
assert_eq!(plain(&open(&format!("/Span << /ActualText {encoded} >> BDC (xy) Tj EMC"))), expected);
|
||||
}
|
||||
assert_eq!(plain(&open("/Span /Named BDC (xy) Tj EMC")), "Named");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_across_operations_styles_and_line_flushes() {
|
||||
let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj [(y) -400 (z)] TJ 1 0 0 rg /F 24 Tf (w) Tj EMC (C) Tj");
|
||||
assert_eq!(plain(&report), "ABC");
|
||||
let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj 0 -40 Td (y) Tj EMC (C) Tj");
|
||||
assert_eq!(plain(&report), "ABC");
|
||||
let ordinary = open_positioned("(x) Tj 0 -40 Td (yC) Tj");
|
||||
assert!((character_origin(&report, 'C') - character_origin(&ordinary, 'C')).hypot() < 0.05);
|
||||
// The replacement belongs to the first placed glyph's style and line.
|
||||
assert_eq!(texts(&report)[0].plain_text(), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_fill_and_stroke_is_not_duplicated() {
|
||||
let report = open("2 Tr /Span << /ActualText (AB) >> BDC [(xy) 0 (zw)] TJ EMC (C) Tj");
|
||||
assert_eq!(plain(&report), "ABC");
|
||||
assert!(texts(&report).iter().flat_map(|t| &t.runs).all(|r| r.style.has_stroke()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_page_reset_and_unbalanced_end() {
|
||||
let bytes = pdf(&["/Span << /ActualText (AB) >> BDC (xy) Tj", "EMC (C) Tj"]);
|
||||
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(plain(&report), "ABC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_outlines_are_unchanged() {
|
||||
let outline = |content| {
|
||||
let report = import_with_report(&pdf(&[content]), &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
|
||||
assert!(texts(&report).is_empty());
|
||||
let mut paths = vec![];
|
||||
report.document.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
paths.push(path.to_bezpath());
|
||||
}
|
||||
});
|
||||
paths
|
||||
};
|
||||
assert_eq!(outline("/Span << /ActualText (AB) >> BDC (xy) Tj EMC"), outline("(xy) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_bounded_replacements_recover_siblings() {
|
||||
let oversized = "A".repeat(64 * 1024 + 1);
|
||||
let report = open(&format!("/Span << /ActualText ({oversized}) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC"));
|
||||
assert_eq!(plain(&report), "xB");
|
||||
assert!(report.warnings.iter().any(|w| w.contains("ActualText beyond")));
|
||||
let deep = format!(
|
||||
"{} /Span << /ActualText (ignored) >> BDC (x) Tj EMC {} /Span << /ActualText (B) >> BDC (y) Tj EMC",
|
||||
"/Span BMC ".repeat(128),
|
||||
"EMC ".repeat(128)
|
||||
);
|
||||
assert_eq!(plain(&open(&deep)), "xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_survives_reader_windows() {
|
||||
let content = format!("/Span << /ActualText (AB) >> BDC (x) Tj {} (y) Tj EMC (C) Tj", "q Q ".repeat(20_000));
|
||||
assert_eq!(plain(&open(&content)), "ABC");
|
||||
}
|
||||
|
||||
fn custom_pdf(content: &str, font: &[u8], resources: &str, extra: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
|
||||
font.to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> {resources} >> /Contents 5 0 R >>")
|
||||
.into_bytes(),
|
||||
stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes()),
|
||||
];
|
||||
objects.extend_from_slice(extra);
|
||||
document(&objects)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_mixed_line_still_reconstructs_ordinary_rtl() {
|
||||
let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 4 beginbfchar <78> <05DD> <79> <05D5> <7A> <05DC> <77> <05E9> endbfchar endcmap end end");
|
||||
let content = format!("(xyzw) Tj /Span << /ActualText {} >> BDC (xy) Tj EMC (xyzw) Tj", utf16("ل\u{200d}ا"));
|
||||
let bytes = custom_pdf(&content, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>", "", &[map]);
|
||||
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(texts(&report).len(), 1);
|
||||
assert_eq!(plain(&report), "שלוםل\u{200d}اשלום");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_rescues_a_glyph_without_unicode() {
|
||||
// Identity-H with no ToUnicode or embedded font: CID 65535 has no character mapping.
|
||||
let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Unmapped /Encoding /Identity-H /DescendantFonts [6 0 R] >>";
|
||||
let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Unmapped /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 >>".to_vec();
|
||||
let descriptor = b"<< /Type /FontDescriptor /FontName /Unmapped /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec();
|
||||
let extra = [descendant, descriptor];
|
||||
let import = |content| import_with_report(&custom_pdf(content, font, "", &extra), &ImportOptions::default()).unwrap();
|
||||
assert!(plain(&import("3 Tr <FFFF> Tj")).is_empty(), "the fixture really has no usable mapping");
|
||||
assert_eq!(plain(&import("3 Tr /Span << /ActualText (AB) >> BDC <FFFFFFFF> Tj EMC")), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_matches_embedded_glyph_identity_not_replacement() {
|
||||
let font = b"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 120 /LastChar 121 /Widths [500 500] >>";
|
||||
let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec();
|
||||
let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let bytes = custom_pdf("/Span << /ActualText (AB) >> BDC (xy) Tj EMC", font, "", &[descriptor, stream("", data)]);
|
||||
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(plain(&report), "AB");
|
||||
assert!(!report.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_nested_mask_interpretation_does_not_consume_page_owner() {
|
||||
let mask = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Font << /F 3 0 R >> >>",
|
||||
b"/Span << /ActualText (MASK) >> BDC BT /F 20 Tf 1 0 0 1 20 700 Tm (mask) Tj ET EMC 1 g 0 0 600 800 re f",
|
||||
);
|
||||
let bytes = custom_pdf(
|
||||
"/Span << /ActualText (AB) >> BDC /Mask gs (xy) Tj EMC",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"/ExtGState << /Mask << /SMask << /S /Luminosity /G 6 0 R >> >> >>",
|
||||
&[mask],
|
||||
);
|
||||
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(plain(&report), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_empty_span_keeps_following_position_without_slots() {
|
||||
for (content, baseline) in [
|
||||
("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(PxyCD) Tj"),
|
||||
("/Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(xyCD) Tj"),
|
||||
] {
|
||||
let report = open_positioned(content);
|
||||
let ordinary = open_positioned(baseline);
|
||||
assert_eq!(plain(&report), if content.starts_with("(P)") { "PCD" } else { "CD" });
|
||||
for c in ['C', 'D'] {
|
||||
assert!((character_origin(&report, c) - character_origin(&ordinary, c)).hypot() < 0.05, "{content}: {c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_waits_for_a_placeable_glyph() {
|
||||
let report = open("/Span << /ActualText (AB) >> BDC -1 0 0 1 20 700 Tm (x) Tj 1 0 0 1 20 660 Tm (y) Tj EMC (C) Tj");
|
||||
assert_eq!(plain(&report), "ABC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_stroke_does_not_restyle_ordinary_prefix() {
|
||||
let report = open("(P) Tj 2 Tr /Span << /ActualText (AB) >> BDC (xyzw) Tj EMC");
|
||||
assert_eq!(plain(&report), "PAB");
|
||||
for text in texts(&report) {
|
||||
for run in text.runs {
|
||||
assert_eq!(run.style.has_stroke(), !run.text.contains('P'), "{:?}", run.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_text_vertical_column_keeps_replacement_once() {
|
||||
let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Vertical /Encoding /Identity-V /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>";
|
||||
let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Vertical /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 /DW2 [880 -1000] >>".to_vec();
|
||||
let descriptor = b"<< /Type /FontDescriptor /FontName /Vertical /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec();
|
||||
let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 3 beginbfchar <0001> <0078> <0002> <0079> <0003> <0043> endbfchar endcmap end end");
|
||||
let extra = [descendant, descriptor, map];
|
||||
for (replacement, expected) in [("AB", "ABC"), ("", "C")] {
|
||||
let content = format!("/Span << /ActualText ({replacement}) >> BDC <00010002> Tj EMC <0003> Tj");
|
||||
let bytes = custom_pdf(&content, font, "", &extra);
|
||||
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(plain(&report), expected);
|
||||
assert!(texts(&report).iter().all(|text| text.vertical));
|
||||
}
|
||||
}
|
||||
@@ -184,8 +184,8 @@ fn shadow_rasters_keep_their_node_and_a_byte_budget() {
|
||||
/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole
|
||||
/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes,
|
||||
/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel
|
||||
/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and
|
||||
/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow
|
||||
/// keeps the raster and places it at the nearest whole pixel, as a full-size raster kept whole is
|
||||
/// placed. A 100 pt glow at 500% (scale 2) and a 2 pt glow
|
||||
/// round the page at 800% (scale 3, near the narrowest blur made coarser).
|
||||
#[test]
|
||||
fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
|
||||
@@ -212,7 +212,9 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
|
||||
assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn");
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.threads = 2;
|
||||
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
|
||||
if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 {
|
||||
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Ink {
|
||||
peniko::Color::from_rgba8(r, g, b, a)
|
||||
}
|
||||
|
||||
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
|
||||
pub(crate) fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
|
||||
match self.base() {
|
||||
Ink::Display | Ink::RichBlack => [r, g, b, a],
|
||||
_ => {
|
||||
|
||||
@@ -243,7 +243,9 @@ impl Renderer {
|
||||
self.live.sweep(g);
|
||||
}
|
||||
|
||||
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
|
||||
/// Draw frame `f` (`w`×`h` pixels): the artboards and the page's art, then the background
|
||||
/// under them. The art blends only with itself, as it does on the canvas, where the paper is
|
||||
/// drawn apart from it: a Screen glow on the bare page stays visible on a white background (#6125).
|
||||
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
|
||||
let (threads, opts) = (self.threads, f.opts);
|
||||
let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h);
|
||||
@@ -258,11 +260,6 @@ impl Renderer {
|
||||
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
|
||||
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
|
||||
self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() };
|
||||
if let Some(bg) = opts.background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(f.ink.fixed(bg));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
if opts.artboards && !opts.outline {
|
||||
// Paper: white on screen, no ink on the ink planes.
|
||||
ctx.set_transform(f.view);
|
||||
@@ -289,7 +286,11 @@ impl Renderer {
|
||||
if self.contexts.len() > 2 {
|
||||
self.contexts.remove(0);
|
||||
}
|
||||
pm.data_as_u8_slice().to_vec()
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
if let Some(bg) = opts.background {
|
||||
over_background(&mut pixels, f.ink.fixed8(bg));
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
/// The page's art: the layers (or the pattern being edited), inside the page group when the
|
||||
@@ -403,10 +404,6 @@ impl Renderer {
|
||||
let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
|
||||
let mut ctx = single_threaded_context(w, w);
|
||||
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
|
||||
if let Some(bg) = background {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
|
||||
}
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
let frame = Frame {
|
||||
mt: false,
|
||||
@@ -426,6 +423,22 @@ impl Renderer {
|
||||
self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone()));
|
||||
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
|
||||
self.end_frame();
|
||||
Some(Rendered { width: w as u32, height: w as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
if let Some(bg) = background {
|
||||
over_background(&mut pixels, bg);
|
||||
}
|
||||
Some(Rendered { width: w as u32, height: w as u32, pixels })
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight).
|
||||
pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) {
|
||||
let alpha = u32::from(a);
|
||||
let under = [u32::from(r) * alpha / 255, u32::from(g) * alpha / 255, u32::from(b) * alpha / 255, alpha];
|
||||
for p in pixels.as_chunks_mut::<4>().0 {
|
||||
let rest = 255 - u32::from(p[3]);
|
||||
for (v, bg) in p.iter_mut().zip(under) {
|
||||
*v = (u32::from(*v) + (bg * rest + 127) / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +116,63 @@ fn page_knockout_and_isolated_blending() {
|
||||
Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off;
|
||||
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED));
|
||||
|
||||
// Page Isolated Blending: a Difference object no longer blends with the white under the page.
|
||||
// A Difference object on the bare page keeps its color: the background goes under the
|
||||
// finished art (#6125), with Page Isolated Blending or without it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
|
||||
red.opacity = 1.0;
|
||||
red.blend = Blend::Difference;
|
||||
d.insert(Some(d.layers[0].id), 0, red).unwrap();
|
||||
assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50));
|
||||
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "the bare page: {:?}", render(&d).pixel(30, 50));
|
||||
d.page_isolate = true;
|
||||
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50));
|
||||
// A white object under it is art: the Difference object inverts it.
|
||||
let id = d.alloc_id();
|
||||
let white = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
|
||||
d.insert(Some(d.layers[0].id), 0, white).unwrap();
|
||||
assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50));
|
||||
}
|
||||
|
||||
/// On a white or blue background, every blend mode on the bare page gives the transparent
|
||||
/// render over that color (#6125).
|
||||
#[test]
|
||||
fn no_blend_mode_reads_the_background() {
|
||||
for mode in Blend::ALL {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
|
||||
red.blend = mode;
|
||||
d.insert(Some(d.layers[0].id), 0, red).unwrap();
|
||||
let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(30, 50);
|
||||
for bg in [[255, 255, 255, 255], [0, 0, 255, 255]] {
|
||||
let got =
|
||||
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(30, 50);
|
||||
let a = i32::from(clear[3]);
|
||||
let want = [0, 1, 2].map(|c| (i32::from(clear[c]) * a + i32::from(bg[c]) * (255 - a)) / 255);
|
||||
assert!((0..3).all(|c| (i32::from(got[c]) - want[c]).abs() <= 3) && got[3] == 255, "{mode:?} on {bg:?}: {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied pixels of alpha 0, 128 and 255 over backgrounds of alpha 0, 128 and 255.
|
||||
#[test]
|
||||
fn over_background_composites_premultiplied_pixels() {
|
||||
let over = |p: [u8; 4], a: u8| {
|
||||
let mut px = p.to_vec();
|
||||
crate::renderer::over_background(&mut px, [200, 100, 0, a]);
|
||||
[px[0], px[1], px[2], px[3]]
|
||||
};
|
||||
const CLEAR: [u8; 4] = [0; 4];
|
||||
const HALF: [u8; 4] = [64, 10, 100, 128];
|
||||
const SOLID: [u8; 4] = [9, 8, 7, 255];
|
||||
assert_eq!(over(CLEAR, 0), CLEAR);
|
||||
assert_eq!(over(HALF, 0), HALF);
|
||||
assert_eq!(over(SOLID, 0), SOLID);
|
||||
assert_eq!(over(CLEAR, 128), [100, 50, 0, 128]);
|
||||
assert_eq!(over(HALF, 128), [114, 35, 100, 192]);
|
||||
assert_eq!(over(SOLID, 128), SOLID);
|
||||
assert_eq!(over(CLEAR, 255), [200, 100, 0, 255]);
|
||||
assert_eq!(over(HALF, 255), [164, 60, 100, 255]);
|
||||
assert_eq!(over(SOLID, 255), SOLID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -771,6 +771,64 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() {
|
||||
assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
/// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document.
|
||||
fn glowing_square(fill: Color, id: &str, params: Value) -> Document {
|
||||
let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx(id, params));
|
||||
doc_with(Document::new(100.0, 100.0), n)
|
||||
}
|
||||
|
||||
/// A glow on the bare page is drawn before the background goes under the art: the pixel is the
|
||||
/// transparent render's over that color, also for a background other than white (#6125).
|
||||
#[test]
|
||||
fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() {
|
||||
let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({}));
|
||||
let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(27, 50);
|
||||
assert!(clear[0] > 20 && clear[0] == clear[1] && clear[2] == 0, "the glow is yellow: {clear:?}");
|
||||
for (bg, name) in [([255, 255, 255, 255], "white"), ([0, 0, 255, 255], "blue")] {
|
||||
let p = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(27, 50);
|
||||
let a = u32::from(clear[3]);
|
||||
let want: Vec<u32> = (0..3).map(|c| (u32::from(clear[c]) * a + u32::from(bg[c]) * (255 - a)) / 255).collect();
|
||||
assert!(a > 0 && (0..3).all(|c| u32::from(p[c]).abs_diff(want[c]) <= 2), "{name}: {p:?}, want {want:?}");
|
||||
assert_eq!(p[3], 255, "{name}: opaque");
|
||||
}
|
||||
}
|
||||
|
||||
/// A thumbnail with a background shows a Screen glow on the bare page, opaque (#6125).
|
||||
#[test]
|
||||
fn a_screen_outer_glow_is_in_a_thumbnail_on_a_background() {
|
||||
let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({}));
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let img = Renderer::new().render_node_thumbnail(&d, &n, 100, Some([255; 4])).unwrap();
|
||||
let pixels = img.pixels.as_chunks::<4>().0;
|
||||
assert!(pixels.iter().all(|p| p[3] == 255), "every pixel is opaque");
|
||||
assert!(pixels.iter().any(|p| p[0] > 235 && p[1] > 235 && p[2] < 235), "the glow shows as yellow on white");
|
||||
}
|
||||
|
||||
/// The ink planes put the background under the art too: on a white background a Screen glow on
|
||||
/// the bare page prints yellow ink, and the bare page around it prints none (#6125).
|
||||
#[test]
|
||||
fn a_screen_outer_glow_prints_ink_on_a_background() {
|
||||
let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({}));
|
||||
let opts = RenderOptions { background: Some([255; 4]), ..Default::default() };
|
||||
let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &opts);
|
||||
let at = |x: usize, y: usize| &inks[(y * 100 + x) * 4..][..4];
|
||||
assert!(at(27, 50)[2] > 10, "yellow ink in the glow: {:?}", at(27, 50));
|
||||
assert_eq!(at(5, 50), [0; 4], "no ink on the bare page");
|
||||
}
|
||||
|
||||
/// An Inner Glow blends with the object's own fill whatever is under the page: white Screen
|
||||
/// lightens a black square, red Screen leaves a white one white, and red Normal shows on it.
|
||||
#[test]
|
||||
fn an_inner_glow_blends_with_its_own_fill_on_a_background() {
|
||||
let red = |mode: &str| json!({"color": "#ff0000", "mode": mode, "opacity": 100});
|
||||
let dark = render(&glowing_square(Color::BLACK, "stylize.innerGlow", json!({"color": "#ffffff", "opacity": 100}))).pixel(32, 50);
|
||||
assert!(dark[0] > 20 && dark[0] == dark[1], "the white glow lightens the black edge: {dark:?}");
|
||||
assert_eq!(render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("screen"))).pixel(32, 50), [255, 255, 255, 255]);
|
||||
let normal = render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("normal"))).pixel(32, 50);
|
||||
assert!(normal[0] == 255 && normal[1] < 245, "red on the white edge: {normal:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_paints_around_that_stroke_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -4645,7 +4645,7 @@ fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() {
|
||||
/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the
|
||||
/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch
|
||||
/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain
|
||||
/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl
|
||||
/// one right after it scrolls two notches in all, and a wheel gesture during which Cmd/Ctrl
|
||||
/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that
|
||||
/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with
|
||||
/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms.
|
||||
@@ -4747,16 +4747,16 @@ fn cmd_wheel_scrolls_sideways_preference() {
|
||||
(zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3,
|
||||
"on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})"
|
||||
);
|
||||
// A trackpad gesture starts with no modifier, and Cmd is pressed and released during it:
|
||||
// egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling
|
||||
// sideways until then.
|
||||
// A gesture egui names as a wheel starts with no modifier, and Cmd is pressed and released
|
||||
// during it: egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps
|
||||
// scrolling sideways until then.
|
||||
let before = *app.view().unwrap();
|
||||
let swipe = |phase, modifiers| egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, 6.0),
|
||||
phase,
|
||||
modifiers,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
};
|
||||
app.synthetic.extend([
|
||||
egui::Event::PointerMoved(at),
|
||||
@@ -4772,7 +4772,7 @@ fn cmd_wheel_scrolls_sideways_preference() {
|
||||
let scroll = (after.center - before.center) * after.zoom;
|
||||
assert!(
|
||||
(after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0,
|
||||
"on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})",
|
||||
"on: a wheel gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})",
|
||||
after.zoom / before.zoom
|
||||
);
|
||||
// An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui
|
||||
@@ -4820,6 +4820,79 @@ fn cmd_wheel_scrolls_sideways_preference() {
|
||||
assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer");
|
||||
}
|
||||
|
||||
/// With Scroll Sideways with Cmd/Ctrl + Wheel on, Cmd/Ctrl with a touchpad zooms about the pointer
|
||||
/// while whole line notches scroll sideways: a trackpad or point source, line deltas smaller than
|
||||
/// a notch, and a whole notch later in the same turn. With Zoom with Mouse Wheel on, Cmd/Ctrl with
|
||||
/// a touchpad zooms too.
|
||||
#[test]
|
||||
fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut t = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
time: Some(t),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
run(&mut app, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let at = rect.center() + vec2(60.0, 40.0);
|
||||
let ev = |unit, dy: f32, source| egui::Event::MouseWheel {
|
||||
unit,
|
||||
delta: vec2(0.0, dy),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
source,
|
||||
};
|
||||
// Sends each event on its own frame, then lets egui finish and end the turn:
|
||||
// → (zoom ratio, scroll in points, how far the point under `at` moved).
|
||||
let mut turn = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let before = *app.view().unwrap();
|
||||
run(app, vec![egui::Event::PointerMoved(at)]);
|
||||
for e in events {
|
||||
run(app, vec![e]);
|
||||
}
|
||||
for _ in 0..40 {
|
||||
run(app, vec![]);
|
||||
}
|
||||
let after = *app.view().unwrap();
|
||||
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
|
||||
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
|
||||
};
|
||||
use egui::{MouseWheelSource as S, MouseWheelUnit as U};
|
||||
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6;
|
||||
let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0;
|
||||
assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default");
|
||||
// A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line.
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms");
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom");
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms");
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom");
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms");
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms");
|
||||
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways");
|
||||
assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways");
|
||||
assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways");
|
||||
// A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again.
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn");
|
||||
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways");
|
||||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms");
|
||||
assert!(
|
||||
zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])),
|
||||
"with Zoom with Mouse Wheel: a whole notch in the same turn zooms"
|
||||
);
|
||||
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms");
|
||||
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways");
|
||||
}
|
||||
|
||||
/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms
|
||||
/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and
|
||||
/// Cmd-wheel alone scrolls sideways.
|
||||
|
||||
@@ -65,6 +65,9 @@ pub(super) struct WheelTurn {
|
||||
/// When the last wheel event came (`InputState::time`), and whether a trackpad sent it.
|
||||
last: f64,
|
||||
trackpad: bool,
|
||||
/// A wheel event of this turn came from a touchpad ([`fine_steps`]), so Cmd/Ctrl zooms for the
|
||||
/// rest of the turn, also through a whole notch.
|
||||
fine: bool,
|
||||
/// The zoom (natural log) wheel notches still have to glide in.
|
||||
pub(super) glide: f64,
|
||||
}
|
||||
@@ -351,11 +354,26 @@ struct ThumbDrag {
|
||||
max: f32,
|
||||
}
|
||||
|
||||
/// Whether a wheel event comes from a touchpad and not from a mouse wheel's notches. egui names
|
||||
/// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line
|
||||
/// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a
|
||||
/// mouse wheel in points, so the web build counts only line deltas that are not whole notches.
|
||||
fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool {
|
||||
let notch = |d: f32| (d - d.round()).abs() < 1e-3;
|
||||
match source {
|
||||
egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true,
|
||||
egui::MouseWheelSource::Wheel => false,
|
||||
egui::MouseWheelSource::Unknown if unit == egui::MouseWheelUnit::Point => !cfg!(target_arch = "wasm32"),
|
||||
egui::MouseWheelSource::Unknown => !notch(delta.x) || !notch(delta.y),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls
|
||||
/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the
|
||||
/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||||
/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. Cmd/Ctrl
|
||||
/// with a touchpad ([`fine_steps`]) zooms under either preference. `line` and `page`: points
|
||||
/// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated
|
||||
/// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does
|
||||
/// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at
|
||||
@@ -385,7 +403,7 @@ pub(super) fn wheel(
|
||||
// `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held
|
||||
// Alt keeps zooming until it ends, also after Alt is let go.
|
||||
for e in &i.events {
|
||||
if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e {
|
||||
if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } = e {
|
||||
// A wheel event that comes after egui's scroll timeout ends the turn first, as
|
||||
// `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else
|
||||
// 0.15 s), also when no frame ran in between.
|
||||
@@ -393,9 +411,11 @@ pub(super) fn wheel(
|
||||
if i.time - turn.last > timeout {
|
||||
turn.gesture = false;
|
||||
turn.held = egui::Modifiers::NONE;
|
||||
turn.fine = false;
|
||||
}
|
||||
turn.last = i.time;
|
||||
turn.trackpad = *source == egui::MouseWheelSource::Trackpad;
|
||||
turn.fine |= fine_steps(*unit, *delta, *source);
|
||||
match phase {
|
||||
egui::TouchPhase::Start => {
|
||||
turn.gesture = true;
|
||||
@@ -413,6 +433,7 @@ pub(super) fn wheel(
|
||||
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
|
||||
turn.gesture = false;
|
||||
turn.held = egui::Modifiers::NONE;
|
||||
turn.fine = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,6 +441,7 @@ pub(super) fn wheel(
|
||||
if !i.is_scrolling() {
|
||||
turn.gesture = false;
|
||||
turn.held = egui::Modifiers::NONE;
|
||||
turn.fine = false;
|
||||
}
|
||||
let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||||
// Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without
|
||||
@@ -427,7 +449,7 @@ pub(super) fn wheel(
|
||||
// default speed, which the app keeps. The canvas scrolls sideways by those points instead.
|
||||
// A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the
|
||||
// fingers' alone, and the canvas leaves it as it is.
|
||||
if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() {
|
||||
if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && !turn.fine && i.multi_touch().is_none() {
|
||||
let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product();
|
||||
let wheel_zoom = zoom / pinch;
|
||||
if wheel_zoom.is_finite() && wheel_zoom > 0.0 {
|
||||
@@ -461,9 +483,15 @@ pub(super) fn wheel(
|
||||
let mut scroll = pan;
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::MouseWheel { unit, delta, modifiers, .. } => {
|
||||
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => {
|
||||
let d = points(unit, *delta);
|
||||
if modifiers.command {
|
||||
// The turn ends after egui's scroll timeout, as above.
|
||||
if i.time - turn.last > 0.150 {
|
||||
turn.fine = false;
|
||||
}
|
||||
turn.last = i.time;
|
||||
turn.fine |= fine_steps(*unit, *delta, *source);
|
||||
if modifiers.command && !turn.fine {
|
||||
scroll.x += d.x + d.y;
|
||||
} else if modifiers.shift {
|
||||
scroll.y += d.x + d.y;
|
||||
|
||||
@@ -24,9 +24,40 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
preview: true,
|
||||
// Load Texture… adds the texture it reads.
|
||||
passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER),
|
||||
// OK could only fail the same way: off, beside the reason the dialog shows (#6140).
|
||||
ok_enabled: Some(|_, d| !(vectorcraft_effects::is_three_d(&d.str("__effect")) && d.fields.contains_key(form::REFUSED))),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The field holding the values a 3D effect's dialog last tried with Preview off.
|
||||
const TRIED: &str = "__tried";
|
||||
|
||||
/// A 3D effect refuses objects it can't build from and values it can't draw, in the dialog as OK
|
||||
/// would: shows why under Preview (#6140). With Preview off no preview has tried `params`, so they
|
||||
/// are tried here, when they change, and rolled back at once.
|
||||
fn three_d_refusal(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, cmd: &str, params: Value) {
|
||||
if d.bool("preview") {
|
||||
d.fields.remove(TRIED);
|
||||
} else if !app.session.in_interaction() && d.fields.get(TRIED) != Some(¶ms) {
|
||||
// Waits for another interaction to end: trying inside it would replace its preview and
|
||||
// cancel it. A failure to begin leaves no interaction: the preview below then reports it.
|
||||
let _ = app.session.begin_interaction(&d.str("__label"));
|
||||
match app.session.preview(cmd, ¶ms) {
|
||||
Ok(_) => {
|
||||
d.fields.remove(form::REFUSED);
|
||||
}
|
||||
Err(e) => form::note_refusal(d, &e),
|
||||
}
|
||||
// Only a try: without an interaction there is nothing to roll back.
|
||||
let _ = app.session.cancel_interaction();
|
||||
d.fields.insert(TRIED.into(), params);
|
||||
}
|
||||
if let Some(why) = d.fields.get(form::REFUSED).and_then(Value::as_str) {
|
||||
ui.add_space(6.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(why).color(Tokens::get(ui.ctx()).warning)).wrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
|
||||
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
|
||||
fn heading_label(d: &Dialog) -> String {
|
||||
@@ -170,7 +201,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
if vectorcraft_effects::is_three_d(&id) {
|
||||
super::revolve::body(app, ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
form::preview(app, ui, d, &d.str("__label"), cmd, params);
|
||||
form::preview(app, ui, d, &d.str("__label"), cmd, params.clone());
|
||||
three_d_refusal(app, ui, d, cmd, params);
|
||||
return false;
|
||||
}
|
||||
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
//! Effect → Effect Gallery: browse Photoshop-style raster filters by category, build a stack, and
|
||||
//! apply it as separate appearance effects in one undo step.
|
||||
//!
|
||||
//! Each filter has a thumbnail, and the dialog has its own preview, as the
|
||||
//! [Filter Gallery](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)
|
||||
//! describes. Both are an original drawing made for VectorCraft (a small landscape), passed through
|
||||
//! the filter. The thumbnails use each filter's defaults. The preview uses the visible stack, or
|
||||
//! the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are
|
||||
//! unchanged.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS};
|
||||
use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS, PixelSpace};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::{DialogSpec, effect, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// Thumbnail of one filter, in pixels. The drawing is original; the filter is this app's.
|
||||
const THUMB: usize = 36;
|
||||
/// In-dialog preview, in pixels, drawn at twice this size.
|
||||
const PREVIEW_W: usize = 120;
|
||||
const PREVIEW_H: usize = 84;
|
||||
|
||||
/// Dialog kind for the Effect Gallery.
|
||||
pub const KIND: &str = "effectGallery";
|
||||
|
||||
@@ -248,6 +264,196 @@ fn effect_label(id: &str) -> String {
|
||||
vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Where the preview rasters sit: one document point per pixel, origin at the top-left.
|
||||
fn preview_space(w: usize, h: usize) -> PixelSpace {
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(wf / 2.0, hf / 2.0),
|
||||
channels: vectorcraft_effects::pixel::Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * wf.hypot(hf),
|
||||
}
|
||||
}
|
||||
|
||||
fn byte(v: f32) -> u8 {
|
||||
if !v.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
v.round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// An original landscape for the gallery (sky, sun, hills, a house, a river). Not a copied picture.
|
||||
fn sample_drawing(w: usize, h: usize) -> Vec<u8> {
|
||||
let mut out = vec![0u8; w.saturating_mul(h).saturating_mul(4)];
|
||||
if w == 0 || h == 0 || out.len() != w * h * 4 {
|
||||
return out;
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let xf = x as f32 / w as f32;
|
||||
let yf = y as f32 / h as f32;
|
||||
let mut rgb = [90.0 + 90.0 * (1.0 - yf), 150.0 + 50.0 * (1.0 - yf), 200.0 + 40.0 * yf];
|
||||
let (dx, dy) = (xf - 0.78, yf - 0.22);
|
||||
if dx * dx + dy * dy < 0.012 {
|
||||
rgb = [252.0, 206.0, 64.0];
|
||||
}
|
||||
let hill = 0.58 + 0.08 * (xf * std::f32::consts::TAU).sin();
|
||||
if yf > hill {
|
||||
rgb = [46.0, 110.0 + 40.0 * (1.0 - (yf - hill).clamp(0.0, 1.0)), 52.0];
|
||||
}
|
||||
if (0.16..0.40).contains(&xf) && (0.46..0.70).contains(&yf) {
|
||||
rgb = [196.0, 64.0, 48.0];
|
||||
}
|
||||
let roof = 0.46 - (xf - 0.28).abs() * 1.15;
|
||||
if (0.12..0.44).contains(&xf) && yf > roof && yf < 0.48 {
|
||||
rgb = [92.0, 48.0, 36.0];
|
||||
}
|
||||
if (0.22..0.32).contains(&xf) && (0.52..0.62).contains(&yf) {
|
||||
rgb = [255.0, 236.0, 150.0];
|
||||
}
|
||||
if yf > 0.80 && (yf * 18.0 + xf * 9.0).sin() > -0.15 {
|
||||
rgb = [36.0, 78.0, 196.0];
|
||||
}
|
||||
let i = (y * w + x) * 4;
|
||||
out[i] = byte(rgb[0]);
|
||||
out[i + 1] = byte(rgb[1]);
|
||||
out[i + 2] = byte(rgb[2]);
|
||||
out[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run one Photoshop-style filter on a premultiplied RGBA buffer. Leaves it unchanged when `id`
|
||||
/// is not one of those filters or the buffer is the wrong size.
|
||||
fn apply_pixel(id: &str, params: &Map<String, Value>, data: &mut [u8], w: usize, h: usize) {
|
||||
let Some(n) = w.checked_mul(h).and_then(|n| n.checked_mul(4)) else { return };
|
||||
if n != data.len() || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let effect = vectorcraft_doc::Effect { id: id.to_string(), params: Value::Object(params.clone()), visible: true };
|
||||
let Some(vectorcraft_effects::RasterFx::Pixel(fx)) = vectorcraft_effects::raster_effects(std::slice::from_ref(&effect)).into_iter().next() else {
|
||||
return;
|
||||
};
|
||||
fx.apply(data, w, h, &preview_space(w, h));
|
||||
}
|
||||
|
||||
fn filtered(id: &str, params: &Map<String, Value>, w: usize, h: usize) -> Vec<u8> {
|
||||
let mut data = sample_drawing(w, h);
|
||||
apply_pixel(id, params, &mut data, w, h);
|
||||
data
|
||||
}
|
||||
|
||||
/// The drawing with `jobs` applied in order (the visible stack, or the filter being browsed).
|
||||
fn preview_rgba(jobs: &[(String, Map<String, Value>)], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut data = sample_drawing(w, h);
|
||||
for (id, params) in jobs {
|
||||
apply_pixel(id, params, &mut data, w, h);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn thumb_pixels(id: &str) -> Vec<u8> {
|
||||
static CACHE: std::sync::LazyLock<std::sync::Mutex<HashMap<String, Vec<u8>>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
if let Some(px) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned() {
|
||||
return px;
|
||||
}
|
||||
// The filter runs outside the lock: a thumbnail is a few thousand pixels, and two threads
|
||||
// computing the same one just keep one copy.
|
||||
let px = filtered(id, &default_params(id), THUMB, THUMB);
|
||||
CACHE.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), px.clone());
|
||||
px
|
||||
}
|
||||
|
||||
/// Straight RGBA for egui, from the filters' premultiplied bytes.
|
||||
fn color_image(data: &[u8], w: usize, h: usize) -> egui::ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w.saturating_mul(h));
|
||||
let (rows, _) = data.as_chunks::<4>();
|
||||
for p in rows {
|
||||
let a = u32::from(p[3]);
|
||||
let (r, g, b) = if a == 0 {
|
||||
(0, 0, 0)
|
||||
} else if a == 255 {
|
||||
(p[0], p[1], p[2])
|
||||
} else {
|
||||
let s = |c: u8| ((u32::from(c) * 255 + a / 2) / a).min(255) as u8;
|
||||
(s(p[0]), s(p[1]), s(p[2]))
|
||||
};
|
||||
pixels.push(egui::Color32::from_rgba_unmultiplied(r, g, b, p[3]));
|
||||
}
|
||||
if pixels.len() != w.saturating_mul(h) {
|
||||
return egui::ColorImage::filled([w.max(1), h.max(1)], egui::Color32::TRANSPARENT);
|
||||
}
|
||||
egui::ColorImage::new([w, h], pixels)
|
||||
}
|
||||
|
||||
fn thumb_tex(ctx: &egui::Context, id: &str) -> egui::TextureHandle {
|
||||
let key = egui::Id::unique("effect-gallery-thumbs");
|
||||
if let Some(tex) = ctx.data(|d| d.get_temp::<HashMap<String, egui::TextureHandle>>(key).and_then(|m| m.get(id).cloned())) {
|
||||
return tex;
|
||||
}
|
||||
let tex = ctx.load_texture(format!("effect-gallery-thumb-{id}"), color_image(&thumb_pixels(id), THUMB, THUMB), egui::TextureOptions::NEAREST);
|
||||
ctx.data_mut(|d| {
|
||||
d.get_temp_mut_or_insert_with(key, HashMap::new).insert(id.to_string(), tex.clone());
|
||||
});
|
||||
tex
|
||||
}
|
||||
|
||||
/// What the in-dialog preview shows: the visible stack, or the browsed filter when nothing is stacked.
|
||||
fn preview_jobs(d: &Dialog, stack: &[StackEntry], browse: &str) -> Vec<(String, Map<String, Value>)> {
|
||||
if !stack.is_empty() {
|
||||
return stack.iter().filter(|e| e.visible).map(|e| (e.effect.clone(), e.params.clone())).collect();
|
||||
}
|
||||
if browse.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { browse_params(d) } else { default_params(browse) };
|
||||
vec![(browse.to_string(), params)]
|
||||
}
|
||||
|
||||
fn preview_key(jobs: &[(String, Map<String, Value>)]) -> String {
|
||||
let rows: Vec<Value> = jobs.iter().map(|(id, params)| json!({ "effect": id, "params": params })).collect();
|
||||
serde_json::to_string(&Value::Array(rows)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn preview_tex(ctx: &egui::Context, key: &str, rgba: &[u8]) -> egui::TextureHandle {
|
||||
let id = egui::Id::unique("effect-gallery-preview");
|
||||
if let Some(tex) = ctx.data(|d| d.get_temp::<(String, egui::TextureHandle)>(id).filter(|(k, _)| k == key).map(|(_, tex)| tex)) {
|
||||
return tex;
|
||||
}
|
||||
let tex = ctx.load_texture("effect-gallery-preview", color_image(rgba, PREVIEW_W, PREVIEW_H), egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key.to_string(), tex.clone())));
|
||||
tex
|
||||
}
|
||||
|
||||
fn preview_area(ui: &mut egui::Ui, d: &Dialog, stack: &[StackEntry], browse: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::subheader(ui, tl!("Preview"));
|
||||
let jobs = preview_jobs(d, stack, browse);
|
||||
let key = preview_key(&jobs);
|
||||
let rgba = preview_rgba(&jobs, PREVIEW_W, PREVIEW_H);
|
||||
let tex = preview_tex(ui.ctx(), &key, &rgba);
|
||||
#[cfg(test)]
|
||||
PREVIEWS_PAINTED.with(|c| c.set(c.get() + 1));
|
||||
let (img_w, img_h) = (PREVIEW_W as f32 * 2.0, PREVIEW_H as f32 * 2.0);
|
||||
ui.horizontal(|ui| {
|
||||
let spare = (ui.available_width() - img_w).max(0.0);
|
||||
ui.add_space(spare / 2.0);
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(img_w, img_h), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, t.panel_darker);
|
||||
let inner = rect.shrink(4.0);
|
||||
ui.painter().image(tex.id(), inner, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
});
|
||||
let caption = jobs.iter().map(|(id, _)| effect_label(id)).collect::<Vec<_>>().join(", ");
|
||||
if !caption.is_empty() {
|
||||
ui.label(egui::RichText::new(caption).color(t.text_dim));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let cat = category_ix(d);
|
||||
@@ -263,6 +469,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
if stack_sel >= stack.len() && !stack.is_empty() {
|
||||
stack_sel = stack.len() - 1;
|
||||
}
|
||||
preview_area(ui, d, &stack, &sel);
|
||||
|
||||
let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel);
|
||||
if !param_effect.is_empty() {
|
||||
widgets::subheader(ui, tl!("Settings"));
|
||||
@@ -293,12 +501,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
widgets::subheader(ui, tl!("Filters"));
|
||||
ui.set_width(220.0);
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
ui.set_width(248.0);
|
||||
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
|
||||
for e in &effects {
|
||||
let picked = e.id == sel;
|
||||
let name = tl!(e.label).trim_end_matches('…').to_string();
|
||||
let resp = ui.add(egui::Button::selectable(picked, name));
|
||||
let image = egui::Image::from_texture(&thumb_tex(ui.ctx(), e.id)).fit_to_exact_size(egui::vec2(THUMB as f32, THUMB as f32));
|
||||
#[cfg(test)]
|
||||
THUMBS_PAINTED.with(|c| c.set(c.get() + 1));
|
||||
let resp = ui.add(egui::Button::selectable(picked, (image, name)).min_size(egui::vec2(200.0, THUMB as f32 + 4.0)));
|
||||
if resp.clicked() {
|
||||
set_selected(d, e.id);
|
||||
set_stack_focus(d, false);
|
||||
@@ -418,6 +629,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static THUMBS_PAINTED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
static PREVIEWS_PAINTED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -476,4 +693,49 @@ mod tests {
|
||||
assert!(!n.appearance.effects[1].visible);
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_shows_thumbnails_and_an_in_dialog_preview() {
|
||||
let plain = super::sample_drawing(super::THUMB, super::THUMB);
|
||||
let mut seen = Vec::new();
|
||||
for cat in 0..super::CATEGORIES.len() {
|
||||
for e in super::effects_in_category(cat) {
|
||||
let thumb = super::thumb_pixels(e.id);
|
||||
assert_eq!(thumb.len(), super::THUMB * super::THUMB * 4, "{}", e.id);
|
||||
assert_ne!(thumb, plain, "{} thumbnail is the untouched drawing", e.id);
|
||||
seen.push(thumb);
|
||||
}
|
||||
}
|
||||
assert!(seen.len() > 2);
|
||||
assert_ne!(seen[0], seen[1], "two filters share one thumbnail");
|
||||
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
app.run("ui.effectGalleryDialog", json!({})).unwrap();
|
||||
super::THUMBS_PAINTED.with(|c| c.set(0));
|
||||
super::PREVIEWS_PAINTED.with(|c| c.set(0));
|
||||
frame(&mut app);
|
||||
let thumbs = super::THUMBS_PAINTED.with(|c| c.get());
|
||||
let previews = super::PREVIEWS_PAINTED.with(|c| c.get());
|
||||
let artistic = super::effects_in_category(0).len() as u32;
|
||||
assert!(thumbs >= artistic, "painted {thumbs} thumbnails, Artistic has {artistic}");
|
||||
assert!(previews >= 1, "the dialog has a preview area");
|
||||
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
let stack = super::stack_of(d);
|
||||
let jobs = super::preview_jobs(d, &stack, &super::selected_id(d));
|
||||
assert_eq!(jobs.len(), 1, "an empty stack previews the filter being browsed");
|
||||
let shown = super::preview_rgba(&jobs, super::PREVIEW_W, super::PREVIEW_H);
|
||||
assert_ne!(shown, super::sample_drawing(super::PREVIEW_W, super::PREVIEW_H));
|
||||
|
||||
let soft = super::filtered("artistic.cutout", &super::default_params("artistic.cutout"), super::PREVIEW_W, super::PREVIEW_H);
|
||||
let hard = super::filtered(
|
||||
"artistic.cutout",
|
||||
&serde_json::from_value(json!({"levels": 8, "edgeSimplicity": 0, "edgeFidelity": 3})).unwrap(),
|
||||
super::PREVIEW_W,
|
||||
super::PREVIEW_H,
|
||||
);
|
||||
assert_ne!(soft, hard, "the preview follows the filter's settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +966,19 @@ fn rail_ends_w(ui: &mut egui::Ui, width: f32, (left, right): (&str, &str)) {
|
||||
/// The field where [`preview`] keeps the parameters it last previewed.
|
||||
pub(super) const PREVIEWED: &str = "__previewed";
|
||||
|
||||
/// The field holding why the dialog's command last refused its values, for the dialog to show.
|
||||
pub(super) const REFUSED: &str = "__refused";
|
||||
|
||||
/// Keeps why the command refused the dialog's values in [`REFUSED`]: a refused value's message is
|
||||
/// written for the user, so it goes without the command's id that the error's text puts before it.
|
||||
pub(super) fn note_refusal(d: &mut Dialog, e: &vectorcraft_engine::EngineError) {
|
||||
let why = match e {
|
||||
vectorcraft_engine::EngineError::BadParams { msg, .. } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
d.fields.insert(REFUSED.into(), json!(why));
|
||||
}
|
||||
|
||||
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
|
||||
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
|
||||
/// or `ui.dialog.set` changed them); turning it off rolls back.
|
||||
@@ -1022,8 +1035,14 @@ pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label
|
||||
if d.fields.get(PREVIEWED) != Some(¶ms) || !app.session.in_interaction() {
|
||||
let _ = app.session.begin_interaction(label);
|
||||
match app.session.preview(cmd, ¶ms) {
|
||||
Ok(r) => result = Some(r),
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(r) => {
|
||||
d.fields.remove(REFUSED);
|
||||
result = Some(r);
|
||||
}
|
||||
Err(e) => {
|
||||
note_refusal(d, &e);
|
||||
app.status(e.to_string());
|
||||
}
|
||||
}
|
||||
d.fields.insert(PREVIEWED.into(), params);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_effects::revolve_options;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
@@ -284,3 +285,94 @@ fn selected_light_gizmo_edits_only_that_light_and_escape_restores_the_list() {
|
||||
h.release(p + vec2(80.0, 0.0), Modifiers::NONE);
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["lights"], before);
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// A fresh document where `build` runs and Extrude & Bevel's dialog opens, its Preview `on`.
|
||||
fn extrude_dialog(build: &[(&str, Value)], on: bool) -> Self {
|
||||
let mut h = Self::new();
|
||||
dialogs::cancel(&mut h.app);
|
||||
h.app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
for (id, p) in build {
|
||||
h.app.run(id, p.clone()).unwrap();
|
||||
}
|
||||
h.app.run("effect.dialog", json!({"effect": vectorcraft_effects::EXTRUDE})).unwrap();
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(on));
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn ok_is_on(&self) -> bool {
|
||||
let d = self.app.ui.dialog.as_ref().unwrap();
|
||||
dialogs::spec(&d.kind).ok_enabled.is_none_or(|f| f(&self.app, d))
|
||||
}
|
||||
|
||||
/// Does the next frame of the dialog draw a text holding `text`?
|
||||
fn shows(&mut self, text: &str) -> bool {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.job.text.contains(text)))
|
||||
}
|
||||
}
|
||||
|
||||
fn clipping_group() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("shape.ellipse", json!({"x":20,"y":20,"width":100,"height":100})),
|
||||
("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100})),
|
||||
("select.all", json!({})),
|
||||
("object.clippingMask.make", json!({})),
|
||||
]
|
||||
}
|
||||
|
||||
/// #6140: on an object a 3D effect can't take, the dialog says why in its own words, and OK, which
|
||||
/// could only fail, is off, where it used to look as if OK did nothing.
|
||||
#[test]
|
||||
fn a_refused_3d_effect_says_why_and_turns_ok_off() {
|
||||
let mut h = Harness::extrude_dialog(&clipping_group(), true);
|
||||
assert!(!h.ok_is_on());
|
||||
assert!(h.shows("requires a path"));
|
||||
assert!(!h.shows("effect.apply"), "the reason is written without the command's id");
|
||||
let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
h.frame(vec![enter], Modifiers::NONE);
|
||||
assert!(h.app.ui.dialog.is_some());
|
||||
}
|
||||
|
||||
/// With Preview off from the start, nothing has tried the effect: the dialog still finds out,
|
||||
/// and trying it leaves no preview open.
|
||||
#[test]
|
||||
fn a_refused_3d_effect_is_found_with_preview_off() {
|
||||
let mut h = Harness::extrude_dialog(&clipping_group(), false);
|
||||
assert!(!h.ok_is_on());
|
||||
assert!(h.shows("requires a path"));
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
/// With Preview off, trying the values waits for another interaction to end: trying them during
|
||||
/// one would replace its preview and cancel it.
|
||||
#[test]
|
||||
fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() {
|
||||
let mut h = Harness::extrude_dialog(&clipping_group(), false);
|
||||
h.app.session.begin_interaction("Drag").unwrap();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.remove("__tried");
|
||||
d.fields.remove(dialogs::form::REFUSED);
|
||||
h.settle();
|
||||
assert!(h.app.session.in_interaction(), "the other interaction goes on");
|
||||
assert!(h.ok_is_on(), "nothing has tried the values yet");
|
||||
h.app.session.cancel_interaction().unwrap();
|
||||
assert!(h.shows("requires a path"));
|
||||
assert!(!h.ok_is_on());
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
/// An object the effect can take keeps OK on, with Preview on or off, and shows no reason.
|
||||
#[test]
|
||||
fn an_accepted_3d_effect_keeps_ok_on() {
|
||||
let build = [("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100}))];
|
||||
for on in [true, false] {
|
||||
let mut h = Harness::extrude_dialog(&build, on);
|
||||
assert!(h.ok_is_on(), "Preview {on}");
|
||||
assert!(!h.shows("requires a path"), "Preview {on}");
|
||||
assert_eq!(h.app.session.in_interaction(), on);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; 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
|
||||
@@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|
||||
| Group | Illustrator | VectorCraft | % | Remaining (h) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Illustrator effects (vector and stylize) | 50 | 50 | 100% | — |
|
||||
| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) |
|
||||
| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | — |
|
||||
| 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 |
|
||||
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
|
||||
|
||||
@@ -48,12 +48,12 @@ its resolution, PDF export warns once per object with the requested and the lowe
|
||||
| Stylize | Glowing Edges | 1/1 | — |
|
||||
| Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | — |
|
||||
| Video | De-Interlace, NTSC Colors | 2/2 | — |
|
||||
| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`) | filter thumbnails; a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) |
|
||||
| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; a thumbnail for each filter and a preview area inside the dialog, both an original landscape drawing passed through the filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html); `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`, `gallery_shows_thumbnails_and_an_in_dialog_preview`) | — |
|
||||
|
||||
The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a
|
||||
catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter
|
||||
(see [calibration](target-app-parity.md#calibration)). Every Photoshop-style filter is in the catalogue, and the Effect
|
||||
Gallery dialog shipped (#2883); its filter thumbnails and in-dialog preview area are the 1–2 h that remain.
|
||||
Gallery dialog shipped (#2883), with a thumbnail for each filter and a preview area inside the dialog. Both use an original landscape drawing passed through the filter; the canvas preview and the one-undo OK are unchanged.
|
||||
|
||||
Film Grain (`artistic.filmGrain`, Effect › Artistic) adds stable monochrome noise to shadows and
|
||||
midtones, with Grain, Highlight Area and Intensity sliders, live preview and appearance editing.
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). |
|
||||
| 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. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; 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
|
||||
@@ -280,7 +280,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) |
|
||||
| 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); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. |
|
||||
| PDF | open, place, save | yes (authored ActualText in editable import, #6165; 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); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #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); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); 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 an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); 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); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; 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`) |
|
||||
@@ -310,6 +310,10 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
|
||||
## PDF editable imports
|
||||
|
||||
- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder.
|
||||
- [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged.
|
||||
- [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags.
|
||||
|
||||
- [x] Place from bytes or unlinked paths, and PDF clipboard paste, keep the selected page's live text. Synthetic two-page regressions in `crates/engine/src/cmd/fileio/tests_pdffidelity.rs` cover both sources, page selection and explicit outlines. The single-page importer honors the same text choice as Open; existing PDF fallback rules are unchanged.
|
||||
|
||||
## Corpus download integrity
|
||||
@@ -322,6 +326,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) |
|
||||
| 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) |
|
||||
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
|
||||
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
|
||||
| 2026-11-07 | minor | EPS and `.ai` editing data: when the page's type is matched with the text objects, a glyph the page's font encoding names with a private-use character (a ligature such as `uniE028`, which says nothing of the letters it stands for) splits the page's word there, and the pieces are matched as words. A ligature in "Quality" no longer reads "Qualy" against the text object: four users' Illustrator 30 EPS files (used locally, not committed) lose the false "some of the page's type has no text object" note, their renders unchanged |
|
||||
| 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. |
|
||||
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -94,7 +94,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
|
||||
| 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) |
|
||||
| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) |
|
||||
| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 1–2 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) |
|
||||
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) |
|
||||
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 11–21 | no | [target-app-parity.md](target-app-parity.md) |
|
||||
@@ -197,6 +197,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
## G9 Illustrator `.ai` and EPS files from real users
|
||||
|
||||
- **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate.
|
||||
|
||||
- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9.
|
||||
- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged.
|
||||
- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged.
|
||||
@@ -313,7 +315,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
|
||||
## G11 Correctness bugs in core tools
|
||||
|
||||
- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged.
|
||||
- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged.
|
||||
|
||||
- **Done, deletion only (#3380):** deleting anchors or segments that disconnect an ordinary contour creates independent selected path objects, preserving appearance, baked transforms, sibling order and one-step Undo/Redo. Existing multi-subpath paths and compound members stay intact. A second Delete removes all selected survivors. Bounds-fitted gradients keep their original placement on the new objects; already placed gradients stay unchanged. Segment endpoint highlighting remains open; broader G11 scope, scores and estimates are unchanged. Evidence: `crates/engine/tests/it/delete_path_pieces.rs` and the Delete/Backspace frame regression in `crates/ui-egui/src/tests/removeanchors.rs`.
|
||||
|
||||
@@ -510,7 +512,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
## G2 Photoshop-style raster effects and the Effect Gallery
|
||||
|
||||
- **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass.
|
||||
- **Missing:** the Effect Gallery's filter thumbnails and a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); all 57 Photoshop-style filters are in the catalogue. Measured in
|
||||
- **Done:** the Effect Gallery's filter thumbnails and in-dialog preview ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Each thumbnail is an original landscape drawing passed through that filter at its defaults; the preview area shows the same drawing through the visible stack, or through the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are unchanged (`dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`). All 57 Photoshop-style filters are in the catalogue. Measured in
|
||||
[effects-parity.md](effects-parity.md).
|
||||
- **Done:** the Effect Gallery (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step (`dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`).
|
||||
- **Done:** Effect › Sketch › Conté Crayon (`sketch.conteCrayon`: Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; original visual approximation, defaults unverified), the last missing filter.
|
||||
@@ -518,7 +520,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
- **Done:** the pixel-filter pipeline and all 57 Photoshop-style filters (Artistic, Blur, Brush Strokes, Distort, Pixelate,
|
||||
Sharpen, Sketch, Stylize, Texture, Video), with live preview and editable native settings. Smart Blur supports Edge Only and Overlay Edge
|
||||
modes. Per-filter detail: [effects-parity.md](effects-parity.md).
|
||||
- **Estimate:** 1–2 h (Effect Gallery thumbnails and in-dialog preview).
|
||||
- **Estimate:** 0 h.
|
||||
|
||||
## G13 Illustrator 2026 (30.x) additions
|
||||
|
||||
@@ -851,10 +853,12 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. |
|
||||
| 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. |
|
||||
| 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. |
|
||||
| 2026-11-09 | minor | G5: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out documented as Done (already in code); Missing trimmed; estimate unchanged. |
|
||||
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
|
||||
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
|
||||
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | G4 type: the custom dictionary (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, kept in a file beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain; scores and estimates unchanged. |
|
||||
|
||||
+7
-5
@@ -779,7 +779,7 @@ point (72 times that value in ppi); `options.ppi` wins over either scale option.
|
||||
default 72 ppi still use 72 ppi; use `--ppi 300` for print output from those documents.
|
||||
|
||||
Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file, except in GIF, WebP and
|
||||
Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused), `antiAlias` (`none`, `art`, `type`: text
|
||||
Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused; drawn under the finished art, so blend modes do not read it), `antiAlias` (`none`, `art`, `type`: text
|
||||
snapped to pixels) and, for PNG, `interlaced`. `width` or `height` sets the size of each file in pixels: each artboard
|
||||
(with `useArtboards`, each file), `rect` or art bounds gets the scale that makes it that wide or high, within 0.01–64
|
||||
pixels per point, and the file stores the resolution that results, as with `ppi`. `width` wins over `height`, and
|
||||
@@ -2872,7 +2872,7 @@ where the layout puts it.
|
||||
`object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in
|
||||
one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are.
|
||||
Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over
|
||||
white; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images,
|
||||
white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images,
|
||||
opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without).
|
||||
`preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`,
|
||||
`textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`)
|
||||
@@ -3995,9 +3995,11 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps;
|
||||
`disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a
|
||||
segment or an anchor of a selected path.
|
||||
|
||||
`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while
|
||||
`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac
|
||||
trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a
|
||||
`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways
|
||||
instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel
|
||||
and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either
|
||||
wheel preference, Cmd/Ctrl zooms about the pointer for the rest of a wheel turn once the turn has a trackpad event,
|
||||
point deltas from an unnamed source in the desktop app, or line deltas that are not whole notches. With `zoomWithMouseWheel` off, once a
|
||||
trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's
|
||||
`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held.
|
||||
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -113,7 +113,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|
||||
2. **`.ai` files from real users** (G9): layer structure (#951). 10–20 h.
|
||||
3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h.
|
||||
4. **Core-tool bugs** (G11): SVG units reverting to points (#864) and a Transform effect in a fill's or stroke's own effects (#885). Shape Builder regions (#937), Asset Export ghost borders (#983 / #1037) and the stalling bezier drag preview (#834) are fixed. 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h.
|
||||
5. **Then:** advanced type (G4) and the Illustrator 2026 additions (G13), then 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2).
|
||||
|
||||
## Next
|
||||
|
||||
@@ -133,7 +133,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |
|
||||
@@ -190,7 +190,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. |
|
||||
| 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
|
||||
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
|
||||
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user