initial commit

This commit is contained in:
2026-09-25 23:32:29 +02:00
commit fff8b677f8
114 changed files with 84233 additions and 0 deletions
+831
View File
@@ -0,0 +1,831 @@
package pdf
import "core:math"
// ---------------------------------------------------------------------------
// Stroking (ISO 32000-2, 8.4.3)
//
// A stroke is converted into an outline that is then FILLED with the nonzero
// rule. Even-odd would be wrong and not merely different: the two sides of a
// stroke overlap wherever a join doubles back, wherever a path crosses itself
// and wherever two dashes land on the same ink, and even-odd punches holes in
// exactly those places.
//
// The outline is built in USER space and then transformed. Line width is a
// user-space quantity (8.4.3.2), so an anisotropic CTM genuinely makes the pen
// elliptical: under `1 0 0 4 0 0 cm` a horizontal line is painted four times
// as thick as a vertical one of the same width. Scaling the width by
// matrix_expansion and stroking in device space would paint both at twice the
// width, which is wrong for both.
//
// Every contour emitted here is wound the same way (clockwise in build space,
// and so consistently one way in device space whatever the CTM does), with the
// single deliberate exception of the inner ring of a closed subpath, which is
// wound the other way so the nonzero rule leaves the hole unpainted. The
// consistency is load-bearing: two overlapping dash pieces wound oppositely
// would cancel and leave a hole where there should be ink.
// ---------------------------------------------------------------------------
// HAIRLINE_WIDTH is the device-space width of the thinnest line the renderer
// will draw. One pixel: "a line width of 0 shall denote the thinnest line that
// can be rendered at device resolution" (8.4.3.2), and the same has to apply
// to any width that would come out thinner, or the table rules, underlines and
// form boxes that fill ordinary business documents vanish at screen scale.
@(private)
HAIRLINE_WIDTH :: 1.0
// MAX_ARC_SEGMENTS bounds one round join or cap, MAX_CIRCLE_SEGMENTS one dot.
// The segment count is normally driven by the flatness tolerance; the cap only
// bites for a pen thousands of pixels across, where the extra error is a
// fraction of a percent of the radius.
@(private)
MAX_ARC_SEGMENTS :: 128
@(private)
MAX_CIRCLE_SEGMENTS :: 256
// MAX_DASH_PIECES is the hard ceiling on dashes generated from one path, and
// DASH_UNBOUNDED is the "rest of the path is solid" sentinel used once it is
// hit. A finite sentinel rather than an infinity, because no infinity may
// reach the geometry.
@(private)
MAX_DASH_PIECES :: 100_000
@(private)
DASH_UNBOUNDED :: 1e30
// MAX_STROKE_POINTS bounds the whole outline. A path with a million segments
// and round joins would otherwise turn into hundreds of megabytes of geometry;
// a truncated stroke is a much better failure than an exhausted heap.
@(private)
MAX_STROKE_POINTS :: 1 << 21
// stroke_cull grows the caller's clip by the farthest a stroke of this pen can
// paint from the path it follows: half the widest device-space thickness --
// floored at a hairline, because a hairline pen is one device pixel wide
// however thin the file said the line was -- times the miter limit for the
// spike a join can add, and two pixels of slack for the flattening tolerance
// and the rounding at the edge. Nothing outside that can mark the clip, so a
// curve that stays outside it may be replaced by its chord.
@(private)
stroke_cull :: proc(cull: Maybe(Rect), width, hi, miter: f64, dashed: bool) -> Maybe(Rect) {
box, have := cull.?
if !have || dashed { return nil }
reach := max(width * hi, HAIRLINE_WIDTH) * 0.5 * miter + 2
if !is_finite(reach) { return nil }
return Rect{box.x0 - reach, box.y0 - reach, box.x1 + reach, box.y1 + reach}
}
// stroke_flatten converts the stroke of `path` under `state` into a fillable
// outline, appending it to `out`. The result is in DEVICE space, already
// flattened, and must be filled with the NONZERO rule.
//
// Anything already in `out` is kept, so several strokes can be accumulated
// into one path -- which is what a clip built from `S n` needs.
//
// `flatness` is the maximum distance, in device pixels, between the true
// outline and the polygon that replaces it.
//
// `cull`, when given, is the device-space region the outline will be drawn
// through. It is passed on to `path_flatten` grown by the farthest the pen can
// reach from the path, so a curve that cannot paint into the clip is not
// subdivided -- unless the stroke is dashed, where it must be: dashes are laid
// out by arc length along the flattened polyline, so shortening an off-screen
// excursion would shift the phase of every dash after it, including the ones
// on screen.
stroke_flatten :: proc(out: ^Flat_Path, path: ^Path, state: ^Stroke_State,
ctm: Matrix, flatness := FLATNESS, cull: Maybe(Rect) = nil) -> Error {
if out == nil || path == nil { return .Invalid_Argument }
if !matrix_is_finite(ctm) { return .Invalid_Argument }
if len(path.verbs) == 0 { return .None }
st := state != nil ? state^ : STROKE_STATE_DEFAULT
// The device-space thickness of a stroke of width w varies with the
// direction of travel between w*lo and w*hi, so these two numbers are
// exactly what the hairline test and the dash-visibility test need.
lo, hi := stroke_scale_extremes(ctm)
if hi == 0 { return .None }
// A singular CTM -- or one so close to singular that its inverse is
// numerical noise -- maps the outline onto a line: zero area, nothing to
// paint. Content is deliberately hidden this way, so it is not an error.
if lo <= hi * 1e-9 { return .None }
inv, invertible := matrix_invert(ctm)
if !invertible { return .None }
tol_dev := flatness
if !is_finite(tol_dev) || tol_dev <= 0 { tol_dev = FLATNESS }
width := st.line_width
if !is_finite(width) || width < 0 { width = 0 }
// The hairline test is against the *widest* thickness the stroke can
// reach. An anisotropic CTM that paints one pixel across one axis and
// fifty across the other is a real stroke, not a hairline, and promoting
// it would replace a wedge of ink with a thread.
hairline := width * hi < HAIRLINE_WIDTH
// ... but the narrow axis of that same wedge still has to be floored, and
// testing only the widest thickness never gets to it. What a stroke of
// width w comes out at in device space depends on the direction it runs
// in -- it is w times the stretch ACROSS that direction, anywhere between
// w*lo and w*hi -- so under `1 0 0 0.02 0 0 cm` a horizontal rule at
// `1 w` is painted 0.02 pixels tall: a 1% grey that reads as blank paper,
// and the same rule at `0 w` is a clean hairline. Widening the pen must
// not make a line disappear.
//
// The pen is an ellipse whenever the CTM is anisotropic, so the fix is to
// floor its short axis at one device pixel and keep its long one: the
// thin direction comes out a hairline, the thick direction keeps its true
// width, and no direction is ever painted thinner than the thinnest line
// the device can draw (8.4.3.2). mupdf and pdfium make this decision with
// a single averaged scale factor instead, which draws this rule but thins
// the wedge case the paragraph above is about.
squashed := !hairline && width * lo < HAIRLINE_WIDTH
sq_post, sq_build, sq_ok := Matrix{}, Matrix{}, false
if squashed {
sq_post, sq_build, sq_ok = stroke_squashed_pen(ctm, width, lo, hi)
}
s := Stroker{
out = out,
line_cap = st.line_cap,
line_join = st.line_join,
// A miter limit below 1 is geometrically impossible (the ratio is
// never less than 1) and files do carry 0; clamping keeps a bevel
// from being forced on every join. 8.4.3.5.
miter = is_finite(st.miter_limit) ? max(st.miter_limit, 1) : 10,
}
if hairline {
// A hairline is built directly in device space, so it comes out
// exactly one pixel wide in every direction rather than one pixel
// on average.
s.radius = HAIRLINE_WIDTH * 0.5
s.to_build = ctm
s.post = MATRIX_IDENTITY
s.post_id = true
s.tol = tol_dev
} else if sq_ok {
// Build space is the one the floored pen is a circle in, exactly as
// for a hairline; the difference is that `post` stretches it back out
// along the CTM's long axis instead of being the identity.
s.radius = HAIRLINE_WIDTH * 0.5
s.to_build = sq_build
s.post = sq_post
s.tol = max(tol_dev / max(width * hi, 1), 1e-9)
} else {
s.radius = width * 0.5
s.to_build = MATRIX_IDENTITY
s.build_id = true
s.post = ctm
// hi rather than the average expansion: under an anisotropic CTM the
// stretched axis is the one that shows the faceting.
s.tol = max(tol_dev / hi, 1e-9)
}
s.pts = make([dynamic]Point, 0, 64)
defer stroke_destroy(&s)
// Flatten into device space so the tolerance means device pixels, then
// pull the polyline back into user space: both the dash pattern and the
// pen are user-space quantities.
scratch := flat_path_make()
defer flat_path_destroy(&scratch)
path_flatten(&scratch, path, ctm, tol_dev, stroke_cull(cull, width, hi, s.miter,
stroke_has_dash(&st)))
if len(scratch.points) == 0 { return .None }
device_len := 0.0
for i in 0 ..< len(scratch.subpaths) {
pts := flat_path_slice(&scratch, i)
for k in 1 ..< len(pts) {
device_len += stroke_len(pts[k].x - pts[k - 1].x, pts[k].y - pts[k - 1].y)
}
if scratch.subpaths[i].closed && len(pts) > 1 {
device_len += stroke_len(pts[0].x - pts[len(pts) - 1].x, pts[0].y - pts[len(pts) - 1].y)
}
}
if !is_finite(device_len) || device_len < 0 { device_len = 0 }
for &p in scratch.points {
p = transform_point(inv, p)
}
dashed := stroke_has_dash(&st)
dash_count := min(st.dash_count, MAX_DASH_ENTRIES)
total := 0.0
if dashed {
for i in 0 ..< dash_count { total += st.dash[i] }
// A pattern whose entire cycle is under one device pixel cannot read
// as anything but a solid line, and dashing it would emit one piece
// per sub-pixel step: unbounded geometry for no visible difference.
if !is_finite(total) || total * hi < 1 { dashed = false }
}
if dashed {
d := Stroke_Dasher{s = &s, count = dash_count, total = total, phase = st.dash_phase}
for i in 0 ..< dash_count { d.pattern[i] = st.dash[i] }
// No more dashes can be told apart than there are pixels along the
// path, so the piece count is capped by the path's device-space
// length. Without it a `[1e-6 1e-6] 0 d` pattern on a 10000-pixel line
// would generate ten billion rectangles.
budget := f64(MAX_DASH_PIECES)
if device_len < budget { budget = device_len }
d.budget = int(budget) + 16
d.piece = make([dynamic]Point, 0, 64)
d.head = make([dynamic]Point, 0, 16)
defer stroke_dasher_destroy(&d)
for i in 0 ..< len(scratch.subpaths) {
stroke_dash_polyline(&d, flat_path_slice(&scratch, i), scratch.subpaths[i].closed)
}
} else {
for i in 0 ..< len(scratch.subpaths) {
stroke_subpath(&s, flat_path_slice(&scratch, i), scratch.subpaths[i].closed)
}
}
return .None
}
// ---------------------------------------------------------------------------
// The stroker
// ---------------------------------------------------------------------------
// Stroker carries everything the outline builders need. "Build space" is the
// space the pen is circular in: user space for an ordinary stroke, device
// space for a hairline.
@(private)
Stroker :: struct {
out: ^Flat_Path,
radius: f64,
line_cap: Line_Cap,
line_join: Line_Join,
miter: f64,
// Flattening tolerance expressed in build space.
tol: f64,
to_build: Matrix, // user space -> build space
build_id: bool,
post: Matrix, // build space -> device space
post_id: bool,
// One subpath's points in build space, reused between subpaths.
pts: [dynamic]Point,
// Index into out.points at which the contour under construction started.
start: int,
// A non-finite point reached this contour, so the whole contour is
// dropped rather than letting a NaN through to the rasteriser.
bad: bool,
full: bool,
}
@(private)
stroke_destroy :: proc(s: ^Stroker) {
delete(s.pts)
s.pts = nil
}
// stroke_scale_extremes returns the singular values of the matrix's linear
// part: the smallest and largest factors by which it stretches a direction.
@(private)
stroke_scale_extremes :: proc "contextless" (m: Matrix) -> (lo, hi: f64) {
sum := m.a * m.a + m.b * m.b + m.c * m.c + m.d * m.d
det := m.a * m.d - m.b * m.c
disc := sum * sum - 4 * det * det
if disc < 0 || !is_finite(disc) { disc = 0 }
root := math.sqrt(disc)
hi2 := (sum + root) * 0.5
lo2 := (sum - root) * 0.5
if lo2 < 0 { lo2 = 0 }
if !is_finite(hi2) || hi2 < 0 { return 0, 0 }
return math.sqrt(lo2), math.sqrt(hi2)
}
// stroke_squashed_pen builds the transform pair for a pen that is under one
// device pixel across its short axis but not its long one. In build space the
// pen is the same circle of radius HAIRLINE_WIDTH/2 a hairline uses, and
// `post` maps that circle onto the CTM's own ellipse with its short semi-axis
// raised to HAIRLINE_WIDTH/2. `post` composed after `to_build` is the CTM to
// the last bit, so the path itself still lands exactly where the CTM puts it;
// only the shape of the pen changes.
//
// ok = false for a CTM whose ellipse cannot be built (an axis that overflows,
// or a product that is not invertible), leaving the caller its ordinary path.
@(private)
stroke_squashed_pen :: proc "contextless" (ctm: Matrix, width, lo, hi: f64) -> (post, to_build: Matrix, ok: bool) {
r := width * 0.5
unit := HAIRLINE_WIDTH * 0.5
major := max(r * hi, unit)
minor := max(r * lo, unit)
// The long axis of the ellipse is the left singular vector of the CTM's
// linear part belonging to `hi`. M*Mt is symmetric with the left singular
// vectors as its eigenvectors and hi^2, lo^2 as its eigenvalues, and for
// [[p q] [q s]] the eigenvector for lambda is (q, lambda-p), or equally
// (lambda-s, q); the longer of the two is the numerically sound one.
//
// M is divided through by `hi` first, which leaves the eigenvectors where
// they were and makes the largest eigenvalue exactly 1, so that a CTM
// scaled by 1e200 cannot overflow the squares.
ma, mb := ctm.a / hi, ctm.b / hi
mc, md := ctm.c / hi, ctm.d / hi
p := ma * ma + mc * mc
s := mb * mb + md * md
q := ma * mb + mc * md
lam := 1.0
v1 := Point{q, lam - p}
v2 := Point{lam - s, q}
u := stroke_len(v1.x, v1.y) >= stroke_len(v2.x, v2.y) ? v1 : v2
l := stroke_len(u.x, u.y)
// Both candidates vanish only when M*Mt is a multiple of the identity, in
// which case every direction is a singular vector and the axes may as
// well be the coordinate ones.
if l <= 0 {
u, l = Point{1, 0}, 1
}
u = Point{u.x / l, u.y / l}
g1 := major / unit
g2 := minor / unit
post = Matrix{a = u.x * g1, b = u.y * g1, c = -u.y * g2, d = u.x * g2, e = 0, f = 0}
inv := matrix_invert(post) or_return
to_build = matrix_mul(ctm, inv)
if !matrix_is_finite(post) || !matrix_is_finite(to_build) {
return MATRIX_IDENTITY, MATRIX_IDENTITY, false
}
return post, to_build, true
}
@(private)
stroke_len :: proc "contextless" (dx, dy: f64) -> f64 {
l := math.sqrt(dx * dx + dy * dy)
return is_finite(l) ? l : 0
}
// ---------------------------------------------------------------------------
// Contour assembly
// ---------------------------------------------------------------------------
@(private)
stroke_contour_begin :: proc(s: ^Stroker) {
s.start = len(s.out.points)
s.bad = false
}
@(private)
stroke_emit :: proc(s: ^Stroker, p: Point) {
if s.full { return }
if len(s.out.points) >= MAX_STROKE_POINTS {
s.full = true
s.bad = true
return
}
if !is_finite(p.x) || !is_finite(p.y) {
s.bad = true
return
}
append(&s.out.points, p)
}
@(private)
stroke_contour_end :: proc(s: ^Stroker) {
count := len(s.out.points) - s.start
// Fewer than three points bounds no area; emitting it would only cost the
// rasteriser a pass over edges that cancel.
if s.bad || count < 3 {
resize(&s.out.points, s.start)
return
}
if !s.post_id {
for k in s.start ..< len(s.out.points) {
q := transform_point(s.post, s.out.points[k])
// stroke_emit checked these points in BUILD space, and the trip
// to device space can overflow one that was perfectly finite
// there: an outline point of order 5e159 under a 1e150 CTM is
// +Inf, and a CTM that rotates or skews adds +Inf to -Inf and
// gets NaN. Neither may reach a Flat_Path -- ARCHITECTURE.md
// rule 5, and this is below path.odin's construction-time filter
// -- and the rasteriser's clamping is not a defence, because it
// is not the only thing that reads one.
if !is_finite(q.x) || !is_finite(q.y) {
resize(&s.out.points, s.start)
return
}
s.out.points[k] = q
}
}
append(&s.out.subpaths, Flat_Subpath{start = s.start, count = count, closed = true})
}
// ---------------------------------------------------------------------------
// One subpath
// ---------------------------------------------------------------------------
// stroke_subpath takes a polyline in USER space, moves it into build space,
// removes the zero-length segments that would leave a normal undefined, and
// emits its outline.
@(private)
stroke_subpath :: proc(s: ^Stroker, user_pts: []Point, closed: bool) {
if s.full || len(user_pts) == 0 { return }
clear(&s.pts)
for up in user_pts {
q := s.build_id ? up : transform_point(s.to_build, up)
if !is_finite(q.x) || !is_finite(q.y) { continue }
if len(s.pts) > 0 {
prev := s.pts[len(s.pts) - 1]
if stroke_len(q.x - prev.x, q.y - prev.y) <= 0 { continue }
}
append(&s.pts, q)
}
if closed {
// A flattened closed subpath does not repeat its first point, but a
// path written "m 0 0 l 10 0 l 0 0 h" ends where it started and the
// repeat would be a zero-length closing segment.
for len(s.pts) > 1 {
back := s.pts[len(s.pts) - 1]
if stroke_len(back.x - s.pts[0].x, back.y - s.pts[0].y) > 0 { break }
pop(&s.pts)
}
}
if len(s.pts) == 0 { return }
if len(s.pts) == 1 {
stroke_dot(s, s.pts[0])
return
}
pts := s.pts[:]
if closed {
// A closed subpath joins its end to its start instead of capping, and
// it needs two rings: the outer one in the direction of travel and the
// inner one against it, so the nonzero rule leaves the middle empty.
stroke_contour_begin(s)
stroke_side(s, pts, true, false)
stroke_contour_end(s)
stroke_contour_begin(s)
stroke_side(s, pts, true, true)
stroke_contour_end(s)
return
}
// An open subpath is one loop: up one side, round the far cap, back down
// the other side (which is the same walk over the reversed points), round
// the near cap.
stroke_contour_begin(s)
_, end_normal, ok := stroke_side(s, pts, false, false)
if ok {
stroke_cap(s, pts[len(pts) - 1], end_normal)
_, start_normal, _ := stroke_side(s, pts, false, true)
stroke_cap(s, pts[0], start_normal)
}
stroke_contour_end(s)
}
// stroke_side walks one side of the polyline, emitting the offset segments and
// the join geometry between them. The offset is taken along the left normal of
// the direction of travel, so walking the points backwards traces the other
// side. `first` and `last` are the normals of the first and last segments,
// which is what the caller needs to place the caps.
@(private)
stroke_side :: proc(s: ^Stroker, pts: []Point, closed, reverse: bool) -> (first, last: Point, ok: bool) {
n := len(pts)
if n < 2 { return }
r := s.radius
seg_count := closed ? n : n - 1
for i in 0 ..< seg_count {
j := (i + 1) % n
a := pts[reverse ? n - 1 - i : i]
b := pts[reverse ? n - 1 - j : j]
dx := b.x - a.x
dy := b.y - a.y
l := stroke_len(dx, dy)
if l <= 0 { continue }
nrm := Point{-dy / l, dx / l}
if ok {
stroke_join(s, a, last, nrm)
} else {
first = nrm
ok = true
}
stroke_emit(s, Point{a.x + r * nrm.x, a.y + r * nrm.y})
stroke_emit(s, Point{b.x + r * nrm.x, b.y + r * nrm.y})
last = nrm
}
if closed && ok {
stroke_join(s, pts[reverse ? n - 1 : 0], last, first)
}
return
}
// stroke_join emits whatever lies between the offset point the previous
// segment ended on (v + r*n0) and the one the next segment starts from
// (v + r*n1). Both of those points are emitted by the caller, so a bevel is
// literally nothing at all.
@(private)
stroke_join :: proc(s: ^Stroker, v, n0, n1: Point) {
cross := n0.x * n1.y - n0.y * n1.x
dot := n0.x * n1.x + n0.y * n1.y
r := s.radius
// Every vertex of a flattened curve is a join between nearly collinear
// segments. When the two offset points are within the flattening
// tolerance of each other, everything a join could add between them --
// arc, miter point or the vertex itself -- lies within that tolerance of
// the straight edge already there. Dropping those keeps a stroked curve
// to roughly twice the size of the curve instead of three times.
cx := r * (n1.x - n0.x)
cy := r * (n1.y - n0.y)
if cx * cx + cy * cy <= s.tol * s.tol { return }
if cross > 0 {
// The inside of the turn: the two offset segments already overlap, so
// there is no gap to fill. Taking the contour through the vertex keeps
// it a single loop and the nonzero rule paints the overlap once.
stroke_emit(s, v)
return
}
if cross == 0 && dot > 0 { return } // collinear: the offsets already meet
switch s.line_join {
case .Bevel:
// Nothing. The straight edge between the two offset points is the
// bevel.
case .Round:
stroke_arc(s, v, n0, n1)
case .Miter:
denom := 1 + dot
if denom > 1e-12 {
// The miter point is where the two offset lines meet:
// (q-v).n0 == (q-v).n1 == r has the solution q = v + r(n0+n1)/(1+n0.n1).
k := r / denom
q := Point{v.x + k * (n0.x + n1.x), v.y + k * (n0.y + n1.y)}
// Miter length over line width, which is the ratio /MiterLimit
// bounds (8.4.3.5): |q-v|/r == sqrt(2/(1+cos)).
if math.sqrt(2 / denom) <= s.miter {
stroke_emit(s, q)
return
}
}
// Over the miter limit, or a 180-degree reversal whose miter runs off
// to infinity: the format says to substitute a bevel, which is what
// emitting nothing gives.
}
}
// stroke_cap emits whatever lies between v + r*n and v - r*n at the end of an
// open subpath. `n` is the left normal of the last direction of travel, so the
// direction of travel itself is (n.y, -n.x).
@(private)
stroke_cap :: proc(s: ^Stroker, v, n: Point) {
r := s.radius
switch s.line_cap {
case .Butt:
// Nothing: the contour closes straight across the end.
case .Square:
dx := n.y
dy := -n.x
stroke_emit(s, Point{v.x + r * (n.x + dx), v.y + r * (n.y + dy)})
stroke_emit(s, Point{v.x + r * (dx - n.x), v.y + r * (dy - n.y)})
case .Round:
stroke_arc(s, v, n, Point{-n.x, -n.y})
}
}
// stroke_arc emits the interior points of the arc that sweeps clockwise from
// n0 to n1 around `centre`. Clockwise is the direction that stays on the outer
// side of a join and outside the end of a cap, which is what keeps every
// contour wound the same way.
@(private)
stroke_arc :: proc(s: ^Stroker, centre, n0, n1: Point) {
r := s.radius
if r <= 0 { return }
a0 := math.atan2(n0.y, n0.x)
a1 := math.atan2(n1.y, n1.x)
sweep := a0 - a1
if sweep < 0 { sweep += math.TAU }
if !(sweep > 0) { return }
count := stroke_arc_steps(sweep, r, s.tol, MAX_ARC_SEGMENTS)
for k in 1 ..< count {
ang := a0 - sweep * f64(k) / f64(count)
stroke_emit(s, Point{centre.x + r * math.cos(ang), centre.y + r * math.sin(ang)})
}
}
// stroke_arc_steps is how many chords an arc of `sweep` radians on a circle of
// radius r needs to stay within `tol` of the true circle. The sagitta of a
// chord subtending t radians is r*(1-cos(t/2)), which is r*t^2/8 for small t,
// so t = sqrt(8*tol/r) is the largest step that keeps inside the tolerance.
@(private)
stroke_arc_steps :: proc "contextless" (sweep, r, tol: f64, limit: int) -> int {
step := math.sqrt(8 * tol / r)
if !(step > 0) || !is_finite(step) { return limit }
want := sweep / step
if !is_finite(want) || want >= f64(limit) { return limit }
n := int(math.ceil(want))
return max(n, 1)
}
// stroke_dot paints a degenerate subpath: a single point, or a closed subpath
// with no length. Strictly 8.4.3.3 paints one only under round caps, but every
// viewer in practice also paints the projecting square, and a dotted line
// written `[0 3] 0 d` with square caps is a real idiom, so both are honoured.
@(private)
stroke_dot :: proc(s: ^Stroker, v: Point) {
r := s.radius
if r <= 0 { return }
switch s.line_cap {
case .Butt:
// A degenerate subpath under butt caps paints nothing at all.
case .Round:
count := stroke_arc_steps(math.TAU, r, s.tol, MAX_CIRCLE_SEGMENTS)
count = max(count, 4)
stroke_contour_begin(s)
// Negative angles: clockwise, matching every other contour.
for k in 0 ..< count {
ang := -math.TAU * f64(k) / f64(count)
stroke_emit(s, Point{v.x + r * math.cos(ang), v.y + r * math.sin(ang)})
}
stroke_contour_end(s)
case .Square:
// The direction of travel is undefined here, so the square is
// axis-aligned in build space.
stroke_contour_begin(s)
stroke_emit(s, Point{v.x - r, v.y - r})
stroke_emit(s, Point{v.x - r, v.y + r})
stroke_emit(s, Point{v.x + r, v.y + r})
stroke_emit(s, Point{v.x + r, v.y - r})
stroke_contour_end(s)
}
}
// ---------------------------------------------------------------------------
// Dashing (ISO 32000-2, 8.4.3.6)
//
// The pattern is measured along the path in user space, starting `phase` units
// into it, and the line cap is applied to both ends of every dash.
// ---------------------------------------------------------------------------
@(private)
Stroke_Dasher :: struct {
s: ^Stroker,
pattern: [MAX_DASH_ENTRIES]f64,
count: int,
total: f64,
phase: f64,
idx: int,
on: bool,
// Length left in the current pattern entry.
left: f64,
// The dash being accumulated, in user space.
piece: [dynamic]Point,
// The opening dash of a closed subpath, held back until the walk comes
// back round to the start; see stroke_dash_polyline.
head: [dynamic]Point,
hold: bool,
budget: int,
}
@(private)
stroke_dasher_destroy :: proc(d: ^Stroke_Dasher) {
delete(d.piece)
delete(d.head)
d.piece = nil
d.head = nil
}
// stroke_dash_reset restarts the pattern at the beginning of a subpath and
// winds it forward by the phase. Each subpath restarts: the phase is measured
// from the start of the subpath, not from the start of the path.
@(private)
stroke_dash_reset :: proc(d: ^Stroke_Dasher) {
clear(&d.piece)
clear(&d.head)
d.hold = false
d.idx = 0
d.on = true
d.left = d.pattern[0]
// An odd-length pattern repeats only after twice its sum, because the
// second pass through it swaps on for off.
cycle := d.count % 2 == 0 ? d.total : d.total * 2
ph := d.phase
if !is_finite(ph) || ph < 0 { ph = 0 }
if cycle > 0 && ph >= cycle {
ph -= math.floor(ph / cycle) * cycle
}
if !is_finite(ph) || ph < 0 { ph = 0 }
// Two passes over the array is all a phase inside one cycle can need; the
// fuel is there because a pattern of zeros would otherwise spin.
fuel := 2 * d.count + 4
for ph > 0 && fuel > 0 {
if ph < d.left {
d.left -= ph
break
}
ph -= d.left
d.on = !d.on
d.idx = (d.idx + 1) % d.count
d.left = d.pattern[d.idx]
fuel -= 1
}
}
@(private)
stroke_dash_flush :: proc(d: ^Stroke_Dasher) {
if len(d.piece) == 0 { return }
if d.hold {
// The opening dash of a closed subpath is not stroked where it ends;
// stroke_dash_polyline decides what it belongs to once the walk gets
// back to the start.
clear(&d.head)
append(&d.head, ..d.piece[:])
d.hold = false
clear(&d.piece)
return
}
stroke_subpath(d.s, d.piece[:], false)
clear(&d.piece)
d.budget -= 1
}
@(private)
stroke_dash_polyline :: proc(d: ^Stroke_Dasher, pts: []Point, closed: bool) {
n := len(pts)
if n == 0 { return }
stroke_dash_reset(d)
if n == 1 {
stroke_subpath(d.s, pts, false)
return
}
if d.on { append(&d.piece, pts[0]) }
// A dash that is still on when the walk returns to the start of a closed
// subpath is ONE dash crossing that vertex, not two dashes that end
// there: 8.4.3.6 has dashes "wrap around curves and corners just as solid
// stroked lines do", and only the ends of a dash are capped. So the
// opening piece is held back and whatever comes back round is joined onto
// the front of it, which leaves the seam vertex in the middle of a single
// piece and gives it the join every other vertex it crosses gets. Without
// this a dashed rectangle loses one of its four corners.
d.hold = closed && d.on
seg_count := closed ? n : n - 1
for i in 0 ..< seg_count {
a := pts[i]
b := pts[(i + 1) % n]
dx := b.x - a.x
dy := b.y - a.y
seg := stroke_len(dx, dy)
if seg <= 0 { continue }
if d.budget <= 0 {
d.on = true
d.left = DASH_UNBOUNDED
}
pos := 0.0
for d.left < seg - pos {
pos += d.left
u := pos / seg
p := Point{a.x + dx * u, a.y + dy * u}
if d.on {
append(&d.piece, p)
stroke_dash_flush(d)
} else {
clear(&d.piece)
append(&d.piece, p)
}
d.on = !d.on
d.idx = (d.idx + 1) % d.count
d.left = d.pattern[d.idx]
if d.budget <= 0 {
// Out of budget. Draw the rest of the path solid rather than
// stopping: a line that loses its dashes reads as a missing
// pattern, a line that stops halfway reads as a broken
// renderer.
d.on = true
d.left = DASH_UNBOUNDED
if len(d.piece) == 0 { append(&d.piece, p) }
break
}
}
d.left -= seg - pos
if d.on { append(&d.piece, b) }
}
if d.hold {
// No gap ever opened, so the whole closed subpath is one dash and is
// stroked exactly as the undashed one would be: two rings, a join at
// every vertex, and no cap anywhere.
d.hold = false
if len(d.piece) > 0 {
stroke_subpath(d.s, d.piece[:], true)
clear(&d.piece)
d.budget -= 1
}
return
}
if len(d.head) > 0 {
// The held piece begins at the seam vertex, which the piece that came
// back round ends at, so appending it whole leaves one repeated point
// that stroke_subpath drops.
if !d.on || len(d.piece) == 0 {
// The pattern went off before the walk got back: the two pieces
// really are separate dashes, and each keeps its own caps.
stroke_dash_flush(d)
}
append(&d.piece, ..d.head[:])
clear(&d.head)
}
stroke_dash_flush(d)
}