Skip to content

Commit

Permalink
LibGfx: Add support for stroke dash patterns
Browse files Browse the repository at this point in the history
This adds support for dash patterns to Path::stroke_to_fill().
This is used in PDFs, <canvas>, and <svg>.

The implementation is based on the <canvas> spec. It seems to do
the right thing for PDF files too.

(This commit only adds the feature to LibGfx. Future commits will
hook this up for PDF, <canvas>, and <svg>.)
  • Loading branch information
nico committed Nov 7, 2024
1 parent 0ccacc1 commit d569238
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 0 deletions.
19 changes: 19 additions & 0 deletions Tests/LibGfx/TestPath.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ TEST_CASE(path_to_fill_miter_linejoin)
}
}

TEST_CASE(path_to_fill_dash)
{
{
Gfx::Path path;
path.move_to({ 0, 0.5 });
path.line_to({ 13, 0.5 });
auto fill = path.stroke_to_fill({ .thickness = 1, .cap_style = Gfx::Path::CapStyle::Butt, .dash_pattern = { 3, 3 }, .dash_offset = 0 });
EXPECT_EQ(fill.to_byte_string(), "M 3,1 L 3,0 L 0,0 L 0,1 L 3,1 Z M 9,1 L 9,0 L 6,0 L 6,1 L 9,1 Z M 13,1 L 13,0 L 12,0 L 12,1 L 13,1 Z");
}

{
Gfx::Path path;
path.move_to({ 0, 0.5 });
path.line_to({ 13, 0.5 });
auto fill = path.stroke_to_fill({ .thickness = 1, .cap_style = Gfx::Path::CapStyle::Butt, .dash_pattern = { 2, 3 }, .dash_offset = 11 });
EXPECT_EQ(fill.to_byte_string(), "M 1,1 L 1,0 L 0,0 L 0,1 L 1,1 Z M 6,1 L 6,0 L 4,0 L 4,1 L 6,1 Z M 11,1 L 11,0 L 9,0 L 9,1 L 11,1 Z");
}
}

TEST_CASE(path_to_string)
{
{
Expand Down
214 changes: 214 additions & 0 deletions Userland/Libraries/LibGfx/Path.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,217 @@ static Vector<FloatPoint, 128> make_pen(float thickness)
return pen_vertices;
}

static void apply_dash_pattern(Vector<Vector<FloatPoint>>& segments, Vector<bool>& segment_is_closed, Vector<float> dash_pattern, float dash_offset)
{
VERIFY(!dash_pattern.is_empty());

// Has to be ensured by callers. (They all double the list, but <canvas> needs to do that in a way that
// is visible to JS accessors, so don't do it here.)
VERIFY(dash_pattern.size() % 2 == 0);

// This implementation is vaguely based on the <canvas> spec. One difference is that the <canvas> spec
// modifies the path in place, while this implementation returns a new path. The spec is written in terms
// of [start, end] intervals that are removed from the input path, while we have to instead add the
// complement of those intervals to the output path. This is done by keeping track of the previous `end`
// value and then filling in the gap between that and the current `start` value on every interval, and
// at the end of each subpath.

Vector<Vector<FloatPoint>> new_segments;

// https://html.spec.whatwg.org/multipage/canvas.html#line-styles:dash-list-5
// 7. Let `pattern width` be the concatenation of all the entries of style's dash list, in coordinate space units.
// (NOTE: The spec means sum, not concatenation.)
float pattern_width = 0;
for (auto& entry : dash_pattern) {
VERIFY(entry >= 0);
pattern_width += entry;
}

// 8. For each subpath `subpath` in `path`, run the following substeps. These substeps mutate the subpaths in `path` in vivo.
for (auto const& [subpath_index, subpath] : enumerate(segments)) {
float end, last_end = 0;

// 1. Let `subpath width` be the length of all the lines of `subpath`, in coordinate space units.
float subpath_width = 0;
for (size_t i = 0; i < subpath.size() - 1; i++)
subpath_width += subpath[i].distance_from(subpath[i + 1]);

// 2. Let `offset` be the value of style's lineDashOffset, in coordinate space units.
float offset = dash_offset;

// 3. While `offset` is greater than `pattern width`, decrement it by pattern width.
// While `offset` is less than zero, increment it by `pattern width`.
// FIXME: Rewrite this using fmodf() in the future, once this has good test coverage.
while (offset > pattern_width)
offset -= pattern_width;
while (offset < 0)
offset += pattern_width;

// 4. Define `L` to be a linear coordinate line defined along all lines in subpath, such that the start of the first line
// in the subpath is defined as coordinate 0, and the end of the last line in the subpath is defined as coordinate `subpath width`.
float L = 0;
size_t current_vertex_index = 0;

auto next_L = [&]() -> float {
return L + subpath[current_vertex_index].distance_from(subpath[current_vertex_index + 1]);
};

auto append_distinct = [](Vector<FloatPoint>& path, FloatPoint p) {
if (path.is_empty() || path.last() != p)
path.append(p);
};

auto skip_until = [&](float target_L) {
while (next_L() < target_L) {
L = next_L();
current_vertex_index++;
}
};

auto append_until = [&](Vector<FloatPoint>& new_subpath, float target_L) {
while (next_L() < target_L) {
L = next_L();
current_vertex_index++;
append_distinct(new_subpath, subpath[current_vertex_index]);
}
};

auto append_lerp = [&](Vector<FloatPoint>& new_subpath, float target_L) {
VERIFY(target_L >= L);
VERIFY(target_L <= next_L());
append_distinct(new_subpath, mix(subpath[current_vertex_index], subpath[current_vertex_index + 1], (target_L - L) / (next_L() - L)));
};

// 5. Let `position` be zero minus offset.
float position = -offset;

// 6. Let `index` be 0.
size_t index = 0;

// 7. Let `current state` be off (the other states being on and zero-on).
// (NOTE: The mentioned "zero-on" state in the spec appears unused.)
enum class State {
Off,
On,
};
State current_state = State::Off;

dash_on:
// 8. Dash on: Let `segment length` be the value of style's dash list's `index`th entry.
float segment_length = dash_pattern[index];

// 9. Increment `position` by `segment length`.
position += segment_length;

// 10. If `position` is greater than `subpath width`, then end these substeps for this subpath and start them again for the next subpath;
// if there are no more subpaths, then jump to the step labeled `convert` instead.
if (position > subpath_width) {
if (last_end < subpath_width) {
// Fill from last_end to subpath_width.
Vector<FloatPoint> new_subpath;

skip_until(last_end);
append_lerp(new_subpath, last_end);
for (++current_vertex_index; current_vertex_index < subpath.size(); ++current_vertex_index)
append_distinct(new_subpath, subpath[current_vertex_index]);

new_segments.append(move(new_subpath));
}
continue;
}

// 11. If `segment length` is nonzero, then let current state be on.
if (segment_length != 0)
current_state = State::On;

// 12. Increment `index` by one.
index++;

// 13. Dash off: Let segment length be the value of style's dash list's `index`th entry.
// (NOTE: The label "Dash off:" in the spec appears unused.)
segment_length = dash_pattern[index];

// 14. Let `start` be the offset `position` on L.
float start = position;

// 15. Increment `position` by `segment length`.
position += segment_length;

// 16. If `position` is less than zero, then jump to the step labeled `post-cut`.
if (position < 0)
goto post_cut;

// 17. If `start` is less than zero, then let `start` be zero.
if (start < 0)
start = 0;

// 18. If `position` is greater than `subpath width`, then let `end` be the offset `subpath width` on `L`. Otherwise, let `end` be the offset `position` on `L`.
end = position > subpath_width ? subpath_width : position;

// 19. Jump to the first appropriate step:
// If segment length is zero and current state is off
// Do nothing, just continue to the next step.
// If current state is off
// Cut the line on which `end` finds itself short at `end` and place a point there, cutting in two the subpath that it was in;
// remove all line segments, joins, points, and subpaths that are between `start` and `end`; and finally place a single point at
// `start` with no lines connecting to it.
// The point has a directionality for the purposes of drawing line caps (see below). The directionality is the direction that
// the original line had at that point (i.e. when `L` was defined above).
// Otherwise
// Cut the line on which `start` finds itself into two at `start` and place a point there, cutting in two the subpath that it was in,
// and similarly cut the line on which `end` finds itself short at end and place a point there, cutting in two the subpath that it was in,
// and then remove all line segments, joins, points, and subpaths that are between `start` and `end`.
if (segment_length == 0 && current_state == State::Off) {
// Do nothing.
} else if (current_state == State::Off) {
Vector<FloatPoint> new_subpath;

skip_until(start);
append_lerp(new_subpath, start);

// FIXME: Store directionality.
new_segments.append(move(new_subpath));
} else {
Vector<FloatPoint> new_subpath;

skip_until(last_end);
append_lerp(new_subpath, last_end);
append_until(new_subpath, start);
append_lerp(new_subpath, start);

new_segments.append(move(new_subpath));
last_end = end;
}

// 20. If start and end are the same point, then this results in just the line being cut in two and two points being inserted there,
// with nothing being removed, unless a join also happens to be at that point, in which case the join must be removed.
// FIXME: Not clear if we have to do anything here, given our inverted interval implementation.

post_cut:
// 21. Post-cut: If position is greater than subpath width, then jump to the step labeled convert.
if (position > subpath_width)
break;

// 22. If segment length is greater than zero, then let positioned-at-on-dash be false.
// (NOTE: The spec doesn't mention positioned-at-on-dash anywhere else.)

// 23. Increment index by one. If it is equal to the number of entries in style's dash list, then let index be 0.
index++;
if (index == dash_pattern.size())
index = 0;

// 24. Return to the step labeled `dash on`.
goto dash_on;
}

segments = move(new_segments);

// This function is only called if there are dashes, and dashes are never closed.
segment_is_closed.resize(segments.size());
for (auto& is_closed : segment_is_closed)
is_closed = false;
}

Path Path::stroke_to_fill(StrokeStyle const& style) const
{
// Note: This convolves a polygon with the path using the algorithm described
Expand Down Expand Up @@ -552,6 +763,9 @@ Path Path::stroke_to_fill(StrokeStyle const& style) const
VERIFY(segment_is_closed.size() == segments.size());
}

if (!style.dash_pattern.is_empty())
apply_dash_pattern(segments, segment_is_closed, style.dash_pattern, style.dash_offset);

Vector<FloatPoint, 128> pen_vertices = make_pen(thickness);

static constexpr auto mod = [](int a, int b) {
Expand Down
3 changes: 3 additions & 0 deletions Userland/Libraries/LibGfx/Path.h
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ class Path {
// The default value of 10 matches the PDF and <canvas> default value, which corresponds to 11.48 degrees.
// (SVG uses a default of 4, which corresponds to 28.96 degrees.)
float miter_limit { 10 };

Vector<float> dash_pattern {};
float dash_offset { 0 };
};
Path stroke_to_fill(StrokeStyle const&) const;

Expand Down

0 comments on commit d569238

Please sign in to comment.