M4.20: Document Setup with bleed, transparency grid, paper and type options

File > Document Setup is a tabbed dialog over one engine command,
document.setup (no params: a query; any keys: one undo step;
document.setUnits stays as the units alias).

- General: units, bleed (four sides with a link toggle), Edit Artboards,
  Show Images in Outline Mode (greyscale pixels under the frame),
  Highlight Substituted Fonts/Glyphs (pink boxes on screen only).
- Transparency: grid size and colour presets or custom colours, Simulate
  Colored Paper, the flattener preset (built-in or saved) and Discard
  White Overprint (Overprint Preview keeps white overprints visible).
- Type: language with its quotes, Use Typographer's Quotes for the Type
  tool and Smart Punctuation, superscript/subscript size and position and
  small caps size (Character panel position/smallCaps; changing the
  setup restyles the text in the same undo step), text export (SVG text
  as text or outlines by default).
- The canvas draws a red bleed outline and the grid in the document's
  colours and cell size as one textured quad, also in rotated views; a
  white Background Contents exports raster formats opaque.

Model: Document.setup (DocSetup, defaulted, skipped when default) and
CharStyle position/small_caps; old files load unchanged.
tests_transparencygrid's helper now finds the grid by its texture (the
grid is one textured mesh instead of per-cell rectangles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 08:28:16 +07:00
co-authored by Claude Opus 5.5
parent 25df58e722
commit d527349f5a
32 changed files with 1804 additions and 129 deletions
+81 -17
View File
@@ -87,6 +87,8 @@ pub struct RenderOptions {
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
}
impl Default for RenderOptions {
@@ -103,6 +105,7 @@ impl Default for RenderOptions {
skip_templates: false,
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
mask_view: None,
highlight_substitutions: false,
}
}
}
@@ -1102,6 +1105,18 @@ impl Renderer {
if let Some(all) = &all {
self.draw_text_items(ctx, f, n, below, all, tb);
}
if f.opts.highlight_substitutions {
let setup = &f.doc.setup;
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
{
if on && !path.elements().is_empty() {
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.set_paint(SUBSTITUTED);
ctx.fill_path(path);
}
}
}
for (i, run) in t.runs.iter().enumerate() {
let Some(path) = g.runs.get(i) else { continue };
if path.elements().is_empty() {
@@ -1159,30 +1174,53 @@ impl Renderer {
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
if f.opts.outline {
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
// Mode its pixels in greyscale under the frame.
let outline = f.opts.outline;
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline) };
if let Some(pm) = pixels {
let pm = if outline { pm } else { self.ink_image(&im.key, &pm, f.ink) };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
if outline {
let mut p = rect.to_path(0.1);
p.apply_affine(im.xf);
self.hairline(ctx, f, &p, [0, 0, 0, 255]);
return;
}
let pm = match self.images.get(&im.key) {
}
/// The decoded pixels of image blob `key` (cached), or a greyscale copy of them.
fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool) -> Option<Arc<Pixmap>> {
let colour = match self.images.get(key) {
Some(p) => p.clone(),
None => {
let Some(blob) = f.doc.images.get(&im.key) else { return };
let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return };
let pm = Arc::new(pm);
self.images.insert(im.key.clone(), pm.clone());
let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?);
self.images.insert(key.to_string(), pm.clone());
pm
}
};
let pm = self.ink_image(&im.key, &pm, f.ink);
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
if !grey {
return Some(colour);
}
let grey_key = format!("{key}\u{0}grey");
if let Some(p) = self.images.get(&grey_key) {
return Some(p.clone());
}
let mut pm = (*colour).clone();
for px in pm.data_mut() {
// The luma of premultiplied components is the premultiplied luma.
let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8;
(px.r, px.g, px.b) = (l, l, l);
}
let pm = Arc::new(pm);
self.images.insert(grey_key, pm.clone());
Some(pm)
}
}
@@ -1261,21 +1299,45 @@ struct TextGeom {
runs: Vec<BezPath>,
all: BezPath,
bounds: Rect,
/// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
substituted_fonts: BezPath,
substituted_glyphs: BezPath,
}
fn text_geom(t: &TextObject) -> TextGeom {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new();
// Per run: is its family missing, and the face its glyphs should come from.
let faces: Vec<(bool, Option<u32>)> =
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing {
&mut substituted_fonts
} else if face.is_some_and(|f| f != g.font_id) {
&mut substituted_glyphs
} else {
continue;
};
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
TextGeom { runs, all, bounds: layout.bounds }
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs }
}
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
@@ -1341,6 +1403,8 @@ mod tests_knockout;
#[cfg(test)]
mod tests_objectfx;
#[cfg(test)]
mod tests_setup;
#[cfg(test)]
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;
+22 -7
View File
@@ -162,21 +162,35 @@ pub(crate) fn overprints(opts: &RenderOptions) -> bool {
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them.
fn multiply_overprints(a: &mut Arc<Node>) {
/// mode of their own keep it), copying only the nodes on the way to them. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply,
AppearanceItem::Stroke(l) if l.overprint && l.blend == BlendMode::Normal => l.blend = BlendMode::Multiply,
AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c);
multiply_overprints(c, discard_white);
}
}
/// A solid paint of paper white (no ink in any colour model).
fn is_white(p: &Paint) -> bool {
let Paint::Solid { color, .. } = p else { return false };
match *color {
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0,
Color::Gray { k } => k <= 0.0,
Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0,
}
}
@@ -223,11 +237,12 @@ pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Do
}
let mut d = doc.clone();
if overprint {
let discard_white = doc.setup.discard_white_overprint;
for l in &mut d.layers {
multiply_overprints(l);
multiply_overprints(l, discard_white);
}
for s in &mut d.symbols {
multiply_overprints(&mut s.art);
multiply_overprints(&mut s.art, discard_white);
}
}
if let Some((proof, visible)) = seps {
+33
View File
@@ -0,0 +1,33 @@
//! Document Setup options the renderer honours.
use std::sync::Arc;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use super::*;
fn doc_with_red_image() -> Document {
let mut d = Document::new(40.0, 40.0);
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
d
}
#[test]
fn outline_mode_draws_images_in_grey_only_when_the_setup_asks() {
let mut d = doc_with_red_image();
let outline = RenderOptions { outline: true, ..Default::default() };
let centre = |d: &Document| Renderer::new().render(d, 40, 40, Affine::IDENTITY, &outline).pixel(20, 20);
assert_eq!(centre(&d)[3], 0, "outline mode draws only the image frame");
d.setup.outline_images = true;
let [r, g, b, a] = centre(&d);
// Red's luma (0.2126 × 255 ≈ 54) in every channel.
assert_eq!(a, 255);
assert!(r == g && g == b && (50..=58).contains(&r), "{:?}", [r, g, b]);
// Preview mode keeps the colours.
assert_eq!(Renderer::new().render(&d, 40, 40, Affine::IDENTITY, &RenderOptions::default()).pixel(20, 20), [255, 0, 0, 255]);
}