PDF import: stroked type keeps its cap, join and miter limit (#7741)

Glyph strokes reached the type's character style as paint and width only,
so stroked PDF text (rendering modes 1, 2, 5, 6, and under ActualText)
came in with butt caps, miter joins and a miter limit of 10. The text
path now carries the graphics state's line cap, line join and miter
limit, taken as paths take them, and glyphs stroked with other ones start
a run of their own.
This commit is contained in:
HassanKhan
2026-11-15 10:10:41 +05:00
parent 282559c105
commit 1301192aeb
6 changed files with 93 additions and 14 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, Walk
use crate::import_shading::{
FunctionBudget, FunctionPaint, clipped, extend_clip, fold_stop_opacity, function_paint, gradient_shape, mesh_shading, shading_gradient,
};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, TypeStroke, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
mod actual_text;
+3 -3
View File
@@ -4,7 +4,7 @@ use super::entry::{
UNNAMED_CHARACTERS_NOTE, max_objects,
};
use super::reshaped;
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, stroke_layer};
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, mean_scale, paints, round3, same_stroke, stroke_layer, type_stroke};
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint,
@@ -961,7 +961,7 @@ impl<'p> Builder<'p> {
}
None => (None, 1.0),
};
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale));
let look = Look {
font: font_key,
family: info.family,
@@ -1109,7 +1109,7 @@ impl<'p> Builder<'p> {
};
// What the glyph is drawn with as an outline, should its line be kept so.
let drawn = paint.clone().map(|p| (p, stroke.cloned()));
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale));
let look = Look {
font: key,
family: info.family,
+8 -1
View File
@@ -1,7 +1,7 @@
use super::cid::{CidText, FontInfo};
use super::{
Affine, Arc, BezPath, BlendMode, Colors, Dash, Families, FillRule, FontNames, FunctionBudget, HashMap, HashSet, ImageBlob, LineCap, LineFacts,
LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine,
LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, TypeStroke,
};
/// Decoded RGBA pixels, width, height and hayro's scale factors.
@@ -277,6 +277,13 @@ pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f
st
}
/// The stroke `p` draws type with in `paint` at `scale`: width, cap, join and miter limit as
/// [`stroke_layer`] takes them (#7741).
pub(super) fn type_stroke(paint: Paint, p: &StrokeProps, scale: f64) -> TypeStroke {
let st = stroke_layer(paint, 1.0, p, scale);
TypeStroke { paint: st.paint, width: st.width, cap: st.cap, join: st.join, miter_limit: st.miter_limit }
}
/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps).
const MAX_DASH_ENTRIES: usize = 64;
/// Most dash periods [`stroke_outline`] cuts a stroke into.
+26 -8
View File
@@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, LineCap, LineJoin, Node, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_text::TextLayout;
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
@@ -70,8 +70,19 @@ pub(crate) struct Upright {
pub top: Point,
}
/// A stroke on type: its paint and width, and the cap, join and miter limit the file strokes the
/// glyphs with (#7741).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TypeStroke {
pub paint: Paint,
pub width: f64,
pub cap: LineCap,
pub join: LineJoin,
pub miter_limit: f64,
}
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
/// scale, fill and stroke (paint and width).
/// scale, fill and stroke.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Look {
pub font: u128,
@@ -82,7 +93,7 @@ pub(crate) struct Look {
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
pub stroke: Option<(Paint, f64)>,
pub stroke: Option<TypeStroke>,
/// The fill, the stroke overprints.
pub overprint_fill: bool,
pub overprint_stroke: bool,
@@ -102,7 +113,11 @@ impl Look {
}
fn style(&self) -> CharStyle {
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
let default = CharStyle::default();
let (stroke, stroke_width, stroke_cap, stroke_join, stroke_miter_limit) = match self.stroke.clone() {
Some(s) => (s.paint, s.width, s.cap, s.join, s.miter_limit),
None => (Paint::None, 0.0, default.stroke_cap, default.stroke_join, default.stroke_miter_limit),
};
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
@@ -112,9 +127,12 @@ impl Look {
fill: self.fill.clone().unwrap_or(Paint::None),
stroke,
stroke_width,
stroke_cap,
stroke_join,
stroke_miter_limit,
overprint_fill: self.overprint_fill,
overprint_stroke: self.overprint_stroke,
..CharStyle::default()
..default
}
}
}
@@ -463,7 +481,7 @@ impl TextLine {
/// 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 {
pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: TypeStroke, 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)) {
@@ -501,10 +519,10 @@ impl TextLine {
Some(bp)
}
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
/// A stroke (`stroke`: paint, width, cap, join and miter limit) over the glyph just drawn at `at` in `font` (fill
/// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't
/// that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: TypeStroke, overprint: bool) -> bool {
let Some((look, ..)) = self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
return false;
};
+51
View File
@@ -55,3 +55,54 @@ fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() {
assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side");
}
}
/// The cap, join and miter limit a PDF strokes its type with, whatever the rendering mode that
/// strokes it (stroke, fill and stroke, and either with a clip), and with replacement text
/// (`ActualText`) too: not butt caps and miter joins (#7741).
#[test]
fn imported_stroked_type_keeps_the_files_cap_join_and_miter_limit() {
use vectorcraft_testkit::pdf::{PdfPage, pdf};
let page = |content: &str| {
let resources = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
pdf(&[PdfPage { resources: resources.into(), ..PdfPage::new(300.0, 200.0, content) }], None)
};
let styles = |content: &str| {
let d = import(&page(content)).unwrap();
let mut runs = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
runs.extend(t.runs.iter().map(|r| r.style.clone()));
}
});
runs
};
let cases = [
("1 J 1 j", (LineCap::Round, LineJoin::Round, 10.0)),
("2 J 2 j 3 M", (LineCap::Square, LineJoin::Bevel, 3.0)),
("0 J 0 j 3 M", (LineCap::Butt, LineJoin::Miter, 3.0)),
];
for (settings, want) in cases {
for mode in ["1", "2", "5", "6"] {
// A word shown at once, a glyph at a time (a stroke over the glyph just filled), and
// in a span with replacement text.
for text in ["(WAVE) Tj", "(W) Tj (A) Tj", "/Span << /ActualText (WAVE) >> BDC (WAVE) Tj EMC"] {
let content = format!("0 g 0 0 0 RG 4 w {settings} BT {mode} Tr /F1 60 Tf 40 110 Td {text} 0 Tr ET");
let runs = styles(&content);
let stroked: Vec<_> = runs.iter().filter(|s| !s.stroke.is_none()).collect();
assert!(!stroked.is_empty(), "{content}: stroked type comes in: {runs:?}");
if matches!(mode, "2" | "6") && !text.starts_with("(WAVE)") {
assert!(stroked.iter().any(|s| !s.fill.is_none()), "{content}: the stroke is over the filled glyphs: {runs:?}");
}
for s in stroked {
assert!((s.stroke_width - 4.0).abs() < 1e-6, "{content}: width {}", s.stroke_width);
let got = (s.stroke_cap, s.stroke_join, s.stroke_miter_limit);
assert_eq!(got, want, "{content}");
}
}
}
}
// Glyphs stroked with another join are a run of their own.
let runs = styles("0 0 0 RG 4 w BT 1 Tr /F1 60 Tf 40 110 Td 1 j (WA) Tj 0 j (VE) Tj 0 Tr ET");
let joins: Vec<_> = runs.iter().map(|s| s.stroke_join).collect();
assert_eq!(joins, [LineJoin::Round, LineJoin::Miter]);
}
File diff suppressed because one or more lines are too long