diff --git a/core/fpdfdoc/BUILD.gn b/core/fpdfdoc/BUILD.gn index 4f4bd25da..3d57cf909 100644 --- a/core/fpdfdoc/BUILD.gn +++ b/core/fpdfdoc/BUILD.gn @@ -89,6 +89,8 @@ source_set("fpdfdoc") { "cpdf_icon.h", "cpdf_iconfit.cpp", "cpdf_iconfit.h", + "cpdf_ink_curves.cpp", + "cpdf_ink_curves.h", "cpdf_interactiveform.cpp", "cpdf_interactiveform.h", "cpdf_link.cpp", @@ -162,6 +164,7 @@ pdfium_unittest_source_set("unittests") { "cpdf_filespec_unittest.cpp", "cpdf_formfield_unittest.cpp", "cpdf_appearancegenerator_unittest.cpp", + "cpdf_ink_curves_unittest.cpp", "cpdf_interactiveform_unittest.cpp", "cpdf_metadata_unittest.cpp", "cpdf_measure_unittest.cpp", diff --git a/core/fpdfdoc/cpdf_annot_unittest.cpp b/core/fpdfdoc/cpdf_annot_unittest.cpp index f6e3785fc..6a823edbd 100644 --- a/core/fpdfdoc/cpdf_annot_unittest.cpp +++ b/core/fpdfdoc/cpdf_annot_unittest.cpp @@ -180,6 +180,9 @@ TEST_F(CPDFAnnotWithPageModuleTest, auto ink_list = pdfium::MakeRetain(); ink_list->Append(CreateQuadPointArrayFromVector({1, 1, 9, 9})); annot_dict->SetFor("InkList", std::move(ink_list)); + // Black: ink with no /C has no stroke and paints nothing. + annot_dict->SetFor(pdfium::annotation::kC, + CreateQuadPointArrayFromVector({0, 0, 0})); CPDF_Annot annot(annot_dict, &doc); EXPECT_EQ(CFX_FloatRect(0, 0, 10, 10), diff --git a/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp b/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp index 71d100eaa..e5069c78f 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp @@ -31,6 +31,7 @@ #include "core/fpdfdoc/cpdf_appearancegenerator_internal.h" #include "core/fpdfdoc/cpdf_cloudy_border.h" #include "core/fpdfdoc/cpdf_defaultappearance.h" +#include "core/fpdfdoc/cpdf_ink_curves.h" #include "core/fpdfdoc/cpdf_richtext.h" #include "core/fpdfdoc/cpdf_richtextlayout.h" #include "core/fxcrt/fx_string_wrappers.h" @@ -431,6 +432,51 @@ bool GenerateDimensionLineAP(APGenerationTarget* target, points); } +// An ink curve's number as Acrobat writes it: to four decimals, without +// trailing zeros (0.5, 527.6667). +void WriteInkNumber(fxcrt::ostringstream& stream, double value) { + if (!std::isfinite(value)) { + stream << "0"; + return; + } + ByteString text = ByteString::Format("%.4f", value); + text.TrimBack('0'); + text.TrimBack('.'); + stream << (text == "-0" ? ByteString("0") : text); +} + +void WriteInkPoint(fxcrt::ostringstream& stream, double x, double y) { + WriteInkNumber(stream, x); + stream << " "; + WriteInkNumber(stream, y); +} + +// One point of ink is Acrobat's dot: a circle of radius 0.5 about it, four +// quarter arcs from its top, clockwise, closed for the fill. +void WriteInkDot(fxcrt::ostringstream& stream, const CFX_PointF& center) { + constexpr double kRadius = 0.5; + // How far each arc's handles reach along its tangents, as Acrobat writes. + constexpr double kHandle = 0.2778; + const double x = center.x; + const double y = center.y; + WriteInkPoint(stream, x, y + kRadius); + stream << " m\n"; + const double arcs[4][6] = { + {x + kHandle, y + kRadius, x + kRadius, y + kHandle, x + kRadius, y}, + {x + kRadius, y - kHandle, x + kHandle, y - kRadius, x, y - kRadius}, + {x - kHandle, y - kRadius, x - kRadius, y - kHandle, x - kRadius, y}, + {x - kRadius, y + kHandle, x - kHandle, y + kRadius, x, y + kRadius}}; + for (const auto& arc : arcs) { + WriteInkPoint(stream, arc[0], arc[1]); + stream << " "; + WriteInkPoint(stream, arc[2], arc[3]); + stream << " "; + WriteInkPoint(stream, arc[4], arc[5]); + stream << " c\n"; + } + stream << "h\n"; +} + } // namespace namespace appearance_generator { @@ -800,20 +846,25 @@ bool GenerateInkAP(APGenerationTarget* target, const std::optional stroke_color = GetStrokeColorString(annot_dict); + // The stroke as Acrobat sets it: the colour, the width unless it is the + // default 1, and round caps. No join: the curves meet smoothly. fxcrt::ostringstream app_stream; - app_stream << "/" << kGSDictName << " gs "; + app_stream << "/" << kGSDictName << " gs\n"; if (stroke_color) { app_stream << *stroke_color; - - app_stream << std::max(GetBorderWidth(annot_dict), 0.0f) << " w "; - - app_stream << "1 J "; // Rounded Line Caps - app_stream << "1 j "; // Rounded Line Joins (for the corners in the middle) - + const float width = std::max(GetBorderWidth(annot_dict), 0.0f); + if (width != 1.0f) { + WriteInkNumber(app_stream, width); + app_stream << " w\n"; + } + app_stream << "1 J\n"; app_stream << GetDashPatternString(annot_dict); } + // Each stroke is drawn as Acrobat draws it (cpdf_ink_curves.h): fitted + // cubics, or a dot for a stroke of one point. std::vector points; + bool dot_fill_set = false; for (size_t i = 0; i < ink_list->size(); i++) { RetainPtr coordinates_array = ink_list->GetArrayAt(i); // An ink stroke needs at least one point to start. @@ -822,19 +873,39 @@ bool GenerateInkAP(APGenerationTarget* target, } const size_t first = points.size(); - points.push_back( - {coordinates_array->GetFloatAt(0), coordinates_array->GetFloatAt(1)}); - for (size_t j = 2; j < coordinates_array->size(); j += 2) { + for (size_t j = 0; j + 1 < coordinates_array->size(); j += 2) { points.push_back({coordinates_array->GetFloatAt(j), coordinates_array->GetFloatAt(j + 1)}); } if (!stroke_color) { continue; } - // The 'm' moves to the stroke's first point; every later one is an 'l'. - app_stream << points[first].x << " " << points[first].y << " m "; - for (size_t j = first + 1; j < points.size(); ++j) { - app_stream << points[j].x << " " << points[j].y << " l "; + const pdfium::span stroke = + pdfium::span(points).subspan(first); + const std::vector curves = FitInkStroke(stroke); + if (curves.empty()) { + // Acrobat fills the dot without setting a fill colour (black under a + // stroke of width 1 or more covers it); ours is the stroke's colour, so + // a thinner dot has no black middle. + if (!dot_fill_set) { + app_stream << GetColorStringWithDefault( + annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), {}, + PaintOperation::kFill); + dot_fill_set = true; + } + WriteInkDot(app_stream, stroke.front()); + app_stream << "B\n"; + continue; + } + WriteInkPoint(app_stream, stroke.front().x, stroke.front().y); + app_stream << " m\n"; + for (const InkCubic& curve : curves) { + WriteInkPoint(app_stream, curve.control1.x, curve.control1.y); + app_stream << " "; + WriteInkPoint(app_stream, curve.control2.x, curve.control2.y); + app_stream << " "; + WriteInkPoint(app_stream, curve.end.x, curve.end.y); + app_stream << " c\n"; } app_stream << "S\n"; } diff --git a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp index acadae858..953e7558f 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp @@ -311,6 +311,8 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralInkAPDoesNotInflateAnnotRect) { auto ink_list = pdfium::MakeRetain(); ink_list->Append(MakeNumberArray({1, 1, 9, 9})); annot_dict->SetFor("InkList", std::move(ink_list)); + // Black: ink with no /C has no stroke and paints nothing. + annot_dict->SetFor(pdfium::annotation::kC, MakeNumberArray({0, 0, 0})); const CFX_FloatRect original_rect = annot_dict->GetRectFor(pdfium::annotation::kRect); diff --git a/core/fpdfdoc/cpdf_ink_curves.cpp b/core/fpdfdoc/cpdf_ink_curves.cpp new file mode 100644 index 000000000..0c0a459ec --- /dev/null +++ b/core/fpdfdoc/cpdf_ink_curves.cpp @@ -0,0 +1,235 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "core/fpdfdoc/cpdf_ink_curves.h" + +#include +#include + +namespace { + +using Point = InkCurvePoint; + +Point operator+(const Point& a, const Point& b) { + return {a.x + b.x, a.y + b.y}; +} + +Point operator-(const Point& a, const Point& b) { + return {a.x - b.x, a.y - b.y}; +} + +Point operator*(const Point& a, double s) { + return {a.x * s, a.y * s}; +} + +double Dot(const Point& a, const Point& b) { + return a.x * b.x + a.y * b.y; +} + +double Distance(const Point& a, const Point& b) { + return std::hypot(a.x - b.x, a.y - b.y); +} + +// `v` at length 1; no direction (0, 0) when it has no length. +Point Unit(const Point& v) { + const double length = std::hypot(v.x, v.y); + if (length == 0) { + return {0, 0}; + } + return {v.x / length, v.y / length}; +} + +// The Bernstein weights of a cubic at `u`. +double B0(double u) { + return (1 - u) * (1 - u) * (1 - u); +} +double B1(double u) { + return 3 * u * (1 - u) * (1 - u); +} +double B2(double u) { + return 3 * u * u * (1 - u); +} +double B3(double u) { + return u * u * u; +} + +struct Cubic { + Point p0; + Point p1; + Point p2; + Point p3; +}; + +Point CubicAt(const Cubic& c, double u) { + return (c.p0 * B0(u) + c.p1 * B1(u)) + (c.p2 * B2(u) + c.p3 * B3(u)); +} + +// A piece of the stroke to fit: its first and last point, and the directions +// the curve leaves the first along and arrives at the last against (pointing +// back into the curve), both unit or none. +struct Piece { + size_t first; + size_t last; + Point leaving; + Point arriving; +}; + +// Where each point of the piece sits along it, 0 to 1, by the length of the +// lines between them. +std::vector ChordLengthPlaces(const std::vector& points, + const Piece& piece) { + std::vector places = {0}; + for (size_t i = piece.first + 1; i <= piece.last; ++i) { + places.push_back(places.back() + Distance(points[i], points[i - 1])); + } + const double length = places.back(); + for (double& place : places) { + place /= length; + } + return places; +} + +// The cubic from the piece's first point to its last, along its two +// directions, whose handle lengths put it closest to the points at their +// places (least squares). Handles that come out shorter than a millionth of +// the piece's span, or backwards, are a third of the span instead. +Cubic LeastSquaresCubic(const std::vector& points, + const Piece& piece, + const std::vector& places) { + const Point& start = points[piece.first]; + const Point& end = points[piece.last]; + double c00 = 0; + double c01 = 0; + double c11 = 0; + double x0 = 0; + double x1 = 0; + for (size_t i = 0; i < places.size(); ++i) { + const double u = places[i]; + const Point a0 = piece.leaving * B1(u); + const Point a1 = piece.arriving * B2(u); + c00 += Dot(a0, a0); + c01 += Dot(a0, a1); + c11 += Dot(a1, a1); + const Point rest = + points[piece.first + i] - + ((start * B0(u) + start * B1(u)) + (end * B2(u) + end * B3(u))); + x0 += Dot(a0, rest); + x1 += Dot(a1, rest); + } + const double det = c00 * c11 - c01 * c01; + double leaving_length = det == 0 ? 0 : (x0 * c11 - x1 * c01) / det; + double arriving_length = det == 0 ? 0 : (c00 * x1 - c01 * x0) / det; + const double span = Distance(end, start); + const double epsilon = 1e-6 * span; + if (leaving_length < epsilon || arriving_length < epsilon) { + leaving_length = span / 3; + arriving_length = span / 3; + } + return {start, start + piece.leaving * leaving_length, + end + piece.arriving * arriving_length, end}; +} + +// The piece's point farthest from the curve at its place: the square of that +// distance, and which point (the last of equals). +struct WorstPoint { + double squared_distance; + size_t index; +}; + +WorstPoint FindWorstPoint(const std::vector& points, + const Piece& piece, + const std::vector& places, + const Cubic& cubic) { + WorstPoint worst = {0, (piece.last - piece.first + 1) / 2 + piece.first}; + for (size_t i = piece.first + 1; i < piece.last; ++i) { + const Point off = CubicAt(cubic, places[i - piece.first]) - points[i]; + const double squared = Dot(off, off); + if (squared >= worst.squared_distance) { + worst = {squared, i}; + } + } + return worst; +} + +double DistanceToSegment(const Point& p, const Point& a, const Point& b) { + const Point ab = b - a; + const double length_squared = Dot(ab, ab); + double t = length_squared == 0 ? 0 : Dot(p - a, ab) / length_squared; + t = t < 0 ? 0 : (t > 1 ? 1 : t); + return Distance(p, a + ab * t); +} + +// How far the curve strays from the lines between the piece's points: the +// farthest of 65 places along it, each from its nearest line. +double DistanceFromLines(const std::vector& points, + const Piece& piece, + const Cubic& cubic) { + constexpr int kSteps = 64; + double farthest = 0; + for (int k = 0; k <= kSteps; ++k) { + const Point at = CubicAt(cubic, static_cast(k) / kSteps); + double nearest = INFINITY; + for (size_t i = piece.first; i < piece.last; ++i) { + nearest = + std::fmin(nearest, DistanceToSegment(at, points[i], points[i + 1])); + } + farthest = std::fmax(farthest, nearest); + } + return farthest; +} + +InkCubic ToInkCubic(const Cubic& cubic) { + return {cubic.p1, cubic.p2, cubic.p3}; +} + +} // namespace + +std::vector FitInkStroke(pdfium::span stroke, + double tolerance) { + std::vector points; + for (const CFX_PointF& point : stroke) { + if (!points.empty() && points.back().x == point.x && + points.back().y == point.y) { + continue; + } + points.push_back({point.x, point.y}); + } + std::vector curves; + if (points.size() < 2) { + return curves; + } + + // The pieces still to fit, the next on top: a split piece's first half is + // fitted (and split) before its second, so the cubics come out in order. + // A stack, not recursion: a long stroke can split once per point. + std::vector pending = { + {0, points.size() - 1, Unit(points[1] - points[0]), + Unit(points[points.size() - 2] - points.back())}}; + while (!pending.empty()) { + const Piece piece = pending.back(); + pending.pop_back(); + const Point& start = points[piece.first]; + const Point& end = points[piece.last]; + if (piece.last - piece.first == 1) { + const double handle = Distance(end, start) / 3; + curves.push_back(ToInkCubic({start, start + piece.leaving * handle, + end + piece.arriving * handle, end})); + continue; + } + const std::vector places = ChordLengthPlaces(points, piece); + const Cubic cubic = LeastSquaresCubic(points, piece, places); + const WorstPoint worst = FindWorstPoint(points, piece, places, cubic); + if (worst.squared_distance < tolerance * tolerance && + DistanceFromLines(points, piece, cubic) < tolerance) { + curves.push_back(ToInkCubic(cubic)); + continue; + } + // The halves meet at the worst point, along the line from the point + // before it to the point after. + const size_t split = worst.index; + const Point back = Unit(points[split - 1] - points[split + 1]); + pending.push_back({split, piece.last, back * -1, piece.arriving}); + pending.push_back({piece.first, split, piece.leaving, back}); + } + return curves; +} diff --git a/core/fpdfdoc/cpdf_ink_curves.h b/core/fpdfdoc/cpdf_ink_curves.h new file mode 100644 index 000000000..83c1a60e7 --- /dev/null +++ b/core/fpdfdoc/cpdf_ink_curves.h @@ -0,0 +1,54 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#ifndef CORE_FPDFDOC_CPDF_INK_CURVES_H_ +#define CORE_FPDFDOC_CPDF_INK_CURVES_H_ + +#include + +#include "core/fxcrt/fx_coordinates.h" +#include "core/fxcrt/span.h" + +// How Acrobat draws an ink stroke (/InkList) when it writes the appearance: +// cubic Béziers fitted to the points as Schneider fits digitized curves ("An +// Algorithm for Automatically Fitting Digitized Curves", Graphics Gems, +// 1990), without his Newton step. Checked against the appearances Acrobat +// wrote, to 0.001 pt, segment for segment: +// +// - Repeated points in a row count once. +// - A piece of the stroke is one cubic. At the stroke's ends it leaves and +// arrives along the first and last segment; where two pieces meet, along +// the line from the point before to the point after (no direction when +// those are the same point: the stroke turns straight back). +// - A piece of two points gets handles a third of its length. +// - A longer piece gets the least-squares cubic over its points, placed by +// chord length. It stays one piece when every point is within the tolerance +// of the curve, at its place, and the curve is within the tolerance of the +// lines between the points. Otherwise it splits at its worst point and both +// halves are fitted again. + +// How far the curves may stray, in points. Acrobat's lies above 1.2747 and at +// most 1.5062 (the dense zigzag in the unit test tells), at any width. +inline constexpr double kInkCurveTolerance = 1.4142135623730951; // sqrt(2) + +// A point of a fitted curve. Doubles, so the fit decides on distances as +// Acrobat's does, not on floats rounded on the way. +struct InkCurvePoint { + double x = 0; + double y = 0; +}; + +// One cubic of a stroke, from the end of the one before (or the stroke's +// first point). +struct InkCubic { + InkCurvePoint control1; + InkCurvePoint control2; + InkCurvePoint end; +}; + +// The cubics that draw `stroke`, from its first point. None when it has fewer +// than two different points: Acrobat draws one point as a dot. +std::vector FitInkStroke(pdfium::span stroke, + double tolerance = kInkCurveTolerance); + +#endif // CORE_FPDFDOC_CPDF_INK_CURVES_H_ diff --git a/core/fpdfdoc/cpdf_ink_curves_unittest.cpp b/core/fpdfdoc/cpdf_ink_curves_unittest.cpp new file mode 100644 index 000000000..1b3a9d5c3 --- /dev/null +++ b/core/fpdfdoc/cpdf_ink_curves_unittest.cpp @@ -0,0 +1,93 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "core/fpdfdoc/cpdf_ink_curves.h" + +#include + +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +// Acrobat's case H: dense like a real pen, a zigzag of 40 points 3 pt apart. +std::vector DenseZigzag() { + std::vector points; + for (int i = 0; i < 40; ++i) { + points.push_back({260.0f + i * 3, 300.0f + (i % 2) * 2 + i * 0.5f}); + } + return points; +} + +void ExpectPoint(const InkCurvePoint& point, double x, double y) { + EXPECT_NEAR(x, point.x, 1e-4); + EXPECT_NEAR(y, point.y, 1e-4); +} + +} // namespace + +// Acrobat draws the dense zigzag with 37 cubics. That pins its tolerance +// between 1.2747 (below it the curve of the first three points strays too far +// from their lines: 39 cubics) and 1.5062 (above it a piece whose worst point +// is that far stays whole: 33). +TEST(CPDFInkCurvesTest, TheDenseZigzagPinsTheTolerance) { + const std::vector points = DenseZigzag(); + EXPECT_EQ(37u, FitInkStroke(points).size()); + EXPECT_EQ(37u, FitInkStroke(points, 1.28).size()); + EXPECT_EQ(37u, FitInkStroke(points, 1.5).size()); + EXPECT_EQ(39u, FitInkStroke(points, 1.27).size()); + EXPECT_EQ(33u, FitInkStroke(points, 1.51).size()); +} + +TEST(CPDFInkCurvesTest, TwoPointsGetHandlesAThirdOfTheirLength) { + static constexpr CFX_PointF kStroke[] = {{60, 520}, {200, 580}}; + const std::vector curves = FitInkStroke(kStroke); + ASSERT_EQ(1u, curves.size()); + // Acrobat: 60 520 m 106.6667 540 153.3333 560 200 580 c + ExpectPoint(curves[0].control1, 106.6667, 540); + ExpectPoint(curves[0].control2, 153.3333, 560); + ExpectPoint(curves[0].end, 200, 580); +} + +TEST(CPDFInkCurvesTest, OnePointHasNoCurves) { + static constexpr CFX_PointF kDot[] = {{260, 550}}; + EXPECT_TRUE(FitInkStroke(kDot).empty()); + static constexpr CFX_PointF kRepeated[] = {{260, 550}, {260, 550}}; + EXPECT_TRUE(FitInkStroke(kRepeated).empty()); + EXPECT_TRUE(FitInkStroke({}).empty()); +} + +// A repeated point counts once: Acrobat's case D draws as if it were there +// once. +TEST(CPDFInkCurvesTest, ARepeatedPointCountsOnce) { + static constexpr CFX_PointF kRepeated[] = { + {330, 520}, {330, 520}, {370, 580}, {410, 520}}; + static constexpr CFX_PointF kOnce[] = {{330, 520}, {370, 580}, {410, 520}}; + const std::vector repeated = FitInkStroke(kRepeated); + const std::vector once = FitInkStroke(kOnce); + ASSERT_EQ(2u, repeated.size()); + ASSERT_EQ(once.size(), repeated.size()); + for (size_t i = 0; i < once.size(); ++i) { + ExpectPoint(repeated[i].control1, once[i].control1.x, once[i].control1.y); + ExpectPoint(repeated[i].control2, once[i].control2.x, once[i].control2.y); + ExpectPoint(repeated[i].end, once[i].end.x, once[i].end.y); + } + // Acrobat: 330 520 m 343.3333 540 345.963 580 370 580 c + ExpectPoint(repeated[0].control1, 343.3333, 540); + ExpectPoint(repeated[0].control2, 345.963, 580); +} + +// A stroke that turns straight back splits where it turns, with no direction +// there: the handles at the turn have no length (Acrobat's case E). +TEST(CPDFInkCurvesTest, TurningStraightBackHasNoHandlesAtTheTurn) { + static constexpr CFX_PointF kStroke[] = {{60, 420}, {140, 470}, {60, 420}}; + const std::vector curves = FitInkStroke(kStroke); + ASSERT_EQ(2u, curves.size()); + // Acrobat: 60 420 m 86.6667 436.6667 140 470 140 470 c + // 140 470 86.6667 436.6667 60 420 c + ExpectPoint(curves[0].control1, 86.6667, 436.6667); + ExpectPoint(curves[0].control2, 140, 470); + ExpectPoint(curves[0].end, 140, 470); + ExpectPoint(curves[1].control1, 140, 470); + ExpectPoint(curves[1].control2, 86.6667, 436.6667); + ExpectPoint(curves[1].end, 60, 420); +} diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index 37cf77bac..5c4403acd 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -236,6 +236,8 @@ pdfium_embeddertest_source_set("embeddertests") { "epdf_form_embeddertest.cpp", # EmbedPDF: covers the annotation API (public/epdf_annot.h). "epdf_annot_embeddertest.cpp", + # EmbedPDF: covers ink appearances against Acrobat's (cpdf_ink_curves.h). + "epdf_ink_appearance_embeddertest.cpp", # EmbedPDF: covers attachments (public/epdf_attachment.h). "epdf_attachment_embeddertest.cpp", # EmbedPDF: covers a document's fonts, the resolver read and the diff --git a/fpdfsdk/epdf_annot_embeddertest.cpp b/fpdfsdk/epdf_annot_embeddertest.cpp index 27bbfffa1..8a9ae26aa 100644 --- a/fpdfsdk/epdf_annot_embeddertest.cpp +++ b/fpdfsdk/epdf_annot_embeddertest.cpp @@ -819,8 +819,9 @@ TEST_F(EPDFAnnotEmbedderTest, GenerateInkAppearanceSetsRectToWhatItPaints) { /*border_width=*/6.0f)); // Whatever /Rect was, too wide or too tight, it becomes what the strokes - // paint: the points 50..120 x 50..90 and half the width (round caps and - // joins), the same on every redraw. + // paint: the curve through the points 50..120 x 50..90, which rises to + // 90.1728 past the middle one, and half the width (round caps), the same on + // every redraw. const FS_RECTF wide{/*left=*/0.0f, /*top=*/200.0f, /*right=*/200.0f, /*bottom=*/0.0f}; const FS_RECTF tight{/*left=*/50.0f, /*top=*/90.0f, /*right=*/120.0f, @@ -832,7 +833,7 @@ TEST_F(EPDFAnnotEmbedderTest, GenerateInkAppearanceSetsRectToWhatItPaints) { FS_RECTF rect; ASSERT_TRUE(FPDFAnnot_GetRect(annot.get(), &rect)); EXPECT_FLOAT_EQ(47.0f, rect.left) << "iteration " << i; - EXPECT_FLOAT_EQ(93.0f, rect.top) << "iteration " << i; + EXPECT_FLOAT_EQ(93.172844f, rect.top) << "iteration " << i; EXPECT_FLOAT_EQ(123.0f, rect.right) << "iteration " << i; EXPECT_FLOAT_EQ(47.0f, rect.bottom) << "iteration " << i; } @@ -844,7 +845,7 @@ TEST_F(EPDFAnnotEmbedderTest, GenerateInkAppearanceSetsRectToWhatItPaints) { FS_RECTF thinner; ASSERT_TRUE(FPDFAnnot_GetRect(annot.get(), &thinner)); EXPECT_FLOAT_EQ(49.0f, thinner.left); - EXPECT_FLOAT_EQ(91.0f, thinner.top); + EXPECT_FLOAT_EQ(91.172844f, thinner.top); EXPECT_FLOAT_EQ(121.0f, thinner.right); EXPECT_FLOAT_EQ(49.0f, thinner.bottom); } @@ -1008,11 +1009,17 @@ TEST_F(EPDFAnnotEmbedderTest, WidthZeroDrawsAHairline) { FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, 255, 0, 0, 255)); ASSERT_TRUE(FPDFAnnot_SetBorder(annot, 0, 0, 0)); ASSERT_TRUE(EPDFAnnot_GenerateAppearance(annot)); + } + for (FPDF_ANNOTATION annot : {line.get(), polyline.get()}) { const std::wstring look = GetNormalAppearance(annot); EXPECT_THAT(look, HasSubstr(L"1 0 0 RG")); EXPECT_THAT(look, HasSubstr(L"0 w ")); EXPECT_THAT(look, HasSubstr(L" l S\n")); } + // Ink writes as Acrobat does, an operator a line, its curves stroked. + const std::wstring ink_look = GetNormalAppearance(ink.get()); + EXPECT_THAT(ink_look, HasSubstr(L"1 0 0 RG\n0 w\n1 J\n")); + EXPECT_THAT(ink_look, HasSubstr(L" c\nS\n")); // The open arrow is as long as a 1 pt line's: 9 pt. EXPECT_THAT(GetNormalAppearance(line.get()), HasSubstr(L"-7.79423 4.5 m 0 0 l -7.79423 -4.5 l S\n")); diff --git a/fpdfsdk/epdf_ink_appearance_embeddertest.cpp b/fpdfsdk/epdf_ink_appearance_embeddertest.cpp new file mode 100644 index 000000000..f8ffab136 --- /dev/null +++ b/fpdfsdk/epdf_ink_appearance_embeddertest.cpp @@ -0,0 +1,348 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +// Ink appearances against the ones Acrobat writes (cpdf_ink_curves.h). Each +// case is an ink annotation Acrobat drew from its /InkList alone, on save, +// with the appearance it wrote; ours must draw the same cubics, every number +// within 0.001 pt. + +#include +#include +#include +#include +#include + +#include "constants/annotation_common.h" +#include "core/fpdfapi/page/cpdf_annotcontext.h" +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fpdfapi/parser/cpdf_stream_acc.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_edit_test_util.h" +#include "public/cpp/fpdf_scopers.h" +#include "public/epdf_annot.h" +#include "public/fpdf_annot.h" +#include "public/fpdf_edit.h" +#include "testing/embedder_test.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" + +using testing::HasSubstr; +using testing::Not; + +namespace { + +using Stroke = std::vector; + +// A path as its numbers, one list per subpath: the point its `m` moves to, +// then each `c`'s six. +using PathNumbers = std::vector>; + +// An ink annotation as Acrobat drew it: its strokes, and the path of the +// appearance Acrobat wrote for them (the `m` and `c` lines). +struct AcrobatInk { + const char* name; + std::vector strokes; + const char* path; +}; + +// The cases of ink-curves.pdf, all at /W 2 in red. One point (case C) is a +// dot, below. +AcrobatInk UnevenSegments() { + return {"A: uneven segments", + {{{60, 620}, {75, 680}, {160, 700}, {175, 630}, {300, 640}}}, + "60 620 m\n" + "65 640 58.902 667.1216 75 680 c\n" + "97.7288 698.183 133.9659 713.0171 160 700 c\n" + "181.3438 689.3281 153.0664 639.4001 175 630 c\n" + "213.4201 613.5342 258.3333 636.6667 300 640 c\n"}; +} + +AcrobatInk Dense() { + return { + "H: dense, like a real pen", + {{{260, 300}, {263, 302.5}, {266, 301}, {269, 303.5}, {272, 302}, + {275, 304.5}, {278, 303}, {281, 305.5}, {284, 304}, {287, 306.5}, + {290, 305}, {293, 307.5}, {296, 306}, {299, 308.5}, {302, 307}, + {305, 309.5}, {308, 308}, {311, 310.5}, {314, 309}, {317, 311.5}, + {320, 310}, {323, 312.5}, {326, 311}, {329, 313.5}, {332, 312}, + {335, 314.5}, {338, 313}, {341, 315.5}, {344, 314}, {347, 316.5}, + {350, 315}, {353, 317.5}, {356, 316}, {359, 318.5}, {362, 317}, + {365, 319.5}, {368, 318}, {371, 320.5}, {374, 319}, {377, 321.5}}}, + "260 300 m\n" + "268.7073 307.2561 257.6714 299.6119 266 301 c\n" + "267.284 301.214 267.716 303.286 269 303.5 c\n" + "270.1028 303.6838 270.8972 301.8162 272 302 c\n" + "273.284 302.214 273.716 304.286 275 304.5 c\n" + "276.1028 304.6838 276.8972 302.8162 278 303 c\n" + "279.284 303.214 279.716 305.286 281 305.5 c\n" + "282.1028 305.6838 282.8972 303.8162 284 304 c\n" + "285.284 304.214 285.716 306.286 287 306.5 c\n" + "288.1028 306.6838 288.8972 304.8162 290 305 c\n" + "291.284 305.214 291.716 307.286 293 307.5 c\n" + "294.1028 307.6838 294.8972 305.8162 296 306 c\n" + "297.284 306.214 297.716 308.286 299 308.5 c\n" + "300.1028 308.6838 300.8972 306.8162 302 307 c\n" + "303.284 307.214 303.716 309.286 305 309.5 c\n" + "306.1028 309.6838 306.8972 307.8162 308 308 c\n" + "309.284 308.214 309.716 310.286 311 310.5 c\n" + "312.1028 310.6838 312.8972 308.8162 314 309 c\n" + "315.284 309.214 315.716 311.286 317 311.5 c\n" + "318.1028 311.6838 318.8972 309.8162 320 310 c\n" + "321.284 310.214 321.716 312.286 323 312.5 c\n" + "324.1028 312.6838 324.8972 310.8162 326 311 c\n" + "327.284 311.214 327.716 313.286 329 313.5 c\n" + "330.1028 313.6838 330.8972 311.8162 332 312 c\n" + "333.284 312.214 333.716 314.286 335 314.5 c\n" + "336.1028 314.6838 336.8972 312.8162 338 313 c\n" + "339.284 313.214 339.716 315.286 341 315.5 c\n" + "342.1028 315.6838 342.8972 313.8162 344 314 c\n" + "345.284 314.214 345.716 316.286 347 316.5 c\n" + "348.1028 316.6838 348.8972 314.8162 350 315 c\n" + "351.284 315.214 351.716 317.286 353 317.5 c\n" + "354.1028 317.6838 354.8972 315.8162 356 316 c\n" + "357.284 316.214 357.716 318.286 359 318.5 c\n" + "360.1028 318.6838 360.8972 316.8162 362 317 c\n" + "363.284 317.214 363.716 319.286 365 319.5 c\n" + "366.1028 319.6838 366.8972 317.8162 368 318 c\n" + "369.284 318.214 369.716 320.286 371 320.5 c\n" + "379.3286 321.8881 368.2927 314.2439 377 321.5 c\n"}; +} + +std::vector AcrobatInks() { + return { + UnevenSegments(), + {"B: two points", + {{{60, 520}, {200, 580}}}, + "60 520 m\n" + "106.6667 540 153.3333 560 200 580 c\n"}, + {"D: a point repeated", + {{{330, 520}, {330, 520}, {370, 580}, {410, 520}}}, + "330 520 m\n" + "343.3333 540 345.963 580 370 580 c\n" + "394.037 580 396.6667 540 410 520 c\n"}, + {"E: straight back on itself", + {{{60, 420}, {140, 470}, {60, 420}}}, + "60 420 m\n" + "86.6667 436.6667 140 470 140 470 c\n" + "140 470 86.6667 436.6667 60 420 c\n"}, + {"F: two strokes", + {{{220, 420}, {260, 470}, {300, 420}}, + {{330, 420}, {370, 470}, {410, 440}}}, + "220 420 m\n" + "233.3333 436.6667 238.6563 470 260 470 c\n" + "281.3438 470 286.6667 436.6667 300 420 c\n" + "330 420 m\n" + "343.3333 436.6667 349.2935 464.8234 370 470 c\n" + "386.169 474.0423 396.6667 450 410 440 c\n"}, + {"G: a tight turn between long runs", + {{{60, 320}, {200, 330}, {205, 300}, {60, 290}}}, + "60 320 m\n" + "106.6667 323.3333 153.6532 336.3927 200 330 c\n" + "210.0429 328.6148 214.7479 302.7851 205 300 c\n" + "158.416 286.6903 108.3333 293.3333 60 290 c\n"}, + Dense(), + }; +} + +PathNumbers PathOf(const std::string& content) { + PathNumbers path; + std::vector operands; + std::istringstream tokens(content); + std::string token; + while (tokens >> token) { + char* end = nullptr; + const float value = std::strtof(token.c_str(), &end); + if (end != token.c_str() && *end == '\0') { + operands.push_back(value); + continue; + } + if (token == "m" && operands.size() >= 2) { + path.push_back({operands.end()[-2], operands.end()[-1]}); + } else if (token == "c" && operands.size() >= 6 && !path.empty()) { + path.back().insert(path.back().end(), operands.end() - 6, operands.end()); + } + operands.clear(); + } + return path; +} + +// `path` moved by (dx, dy). +PathNumbers Moved(PathNumbers path, float dx, float dy) { + for (std::vector& subpath : path) { + for (size_t i = 0; i < subpath.size(); ++i) { + subpath[i] += i % 2 == 0 ? dx : dy; + } + } + return path; +} + +std::vector Moved(std::vector strokes, float dx, float dy) { + for (Stroke& stroke : strokes) { + for (FS_POINTF& point : stroke) { + point.x += dx; + point.y += dy; + } + } + return strokes; +} + +void ExpectSamePath(const PathNumbers& expected, const PathNumbers& actual) { + ASSERT_EQ(expected.size(), actual.size()); + for (size_t i = 0; i < expected.size(); ++i) { + ASSERT_EQ(expected[i].size(), actual[i].size()) << "subpath " << i; + for (size_t j = 0; j < expected[i].size(); ++j) { + EXPECT_NEAR(expected[i][j], actual[i][j], 0.001f) + << "subpath " << i << ", number " << j; + } + } +} + +// The content of `annot`'s normal appearance. +std::string AppearanceOf(FPDF_ANNOTATION annot) { + const CPDF_Dictionary* annot_dict = + CPDFAnnotContextFromFPDFAnnotation(annot)->GetAnnotDict(); + RetainPtr ap_dict = + annot_dict->GetDictFor(pdfium::annotation::kAP); + RetainPtr normal = + ap_dict ? ap_dict->GetStreamFor("N") : nullptr; + if (!normal) { + ADD_FAILURE() << "no normal appearance"; + return std::string(); + } + auto stream_acc = pdfium::MakeRetain(std::move(normal)); + stream_acc->LoadAllDataFiltered(); + const pdfium::span data = stream_acc->GetSpan(); + return std::string(data.begin(), data.end()); +} + +// A red ink annotation of `strokes` at `width`, with the appearance we draw +// for it. +ScopedFPDFAnnotation NewRedInk(FPDF_PAGE page, + const std::vector& strokes, + float width) { + ScopedFPDFAnnotation ink(FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK)); + EXPECT_TRUE(ink); + for (const Stroke& stroke : strokes) { + EXPECT_GE(FPDFAnnot_AddInkStroke(ink.get(), stroke.data(), stroke.size()), + 0); + } + EXPECT_TRUE( + FPDFAnnot_SetColor(ink.get(), FPDFANNOT_COLORTYPE_Color, 255, 0, 0, 255)); + EXPECT_TRUE(FPDFAnnot_SetBorder(ink.get(), 0, 0, width)); + EXPECT_TRUE(EPDFAnnot_GenerateAppearance(ink.get())); + return ink; +} + +} // namespace + +class EPDFInkAppearanceEmbedderTest : public EmbedderTest {}; + +TEST_F(EPDFInkAppearanceEmbedderTest, DrawsAcrobatsCurves) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 612, 792)); + ASSERT_TRUE(page); + for (const AcrobatInk& acrobat : AcrobatInks()) { + SCOPED_TRACE(acrobat.name); + ScopedFPDFAnnotation ink = NewRedInk(page.get(), acrobat.strokes, 2); + const std::string content = AppearanceOf(ink.get()); + // Set as Acrobat sets it: colour, width, round caps, and no join. + EXPECT_THAT(content, HasSubstr("1 0 0 RG\n2 w\n1 J\n")); + EXPECT_THAT(content, Not(HasSubstr(" j\n"))); + ExpectSamePath(PathOf(acrobat.path), PathOf(content)); + } +} + +// One point is Acrobat's dot: a circle of radius 0.5, filled and stroked. +// Acrobat leaves the fill colour at its black default, which a stroke of +// width 1 or more covers; ours fills in the stroke's colour. +TEST_F(EPDFInkAppearanceEmbedderTest, OnePointIsAcrobatsDot) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 612, 792)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation ink = NewRedInk(page.get(), {{{260, 550}}}, 2); + EXPECT_THAT(AppearanceOf(ink.get()), + HasSubstr("1 0 0 RG\n2 w\n1 J\n1 0 0 rg\n" + "260 550.5 m\n" + "260.2778 550.5 260.5 550.2778 260.5 550 c\n" + "260.5 549.7222 260.2778 549.5 260 549.5 c\n" + "259.7222 549.5 259.5 549.7222 259.5 550 c\n" + "259.5 550.2778 259.7222 550.5 260 550.5 c\n" + "h\n" + "B\n")); +} + +// ink-widths.pdf: Acrobat drew the dense stroke and the uneven one at /W 0.5, +// 1, 4 and 8, each 170 pt below the one before, with the same curves (cases H +// and A moved). The width doesn't change the fit; 1, the default, isn't +// written. +TEST_F(EPDFInkAppearanceEmbedderTest, TheCurvesDoNotDependOnTheWidth) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 612, 792)); + ASSERT_TRUE(page); + struct Width { + float width; + const char* written; + }; + static constexpr Width kWidths[] = {{0.5f, "1 0 0 RG\n0.5 w\n1 J\n"}, + {1, "1 0 0 RG\n1 J\n"}, + {4, "1 0 0 RG\n4 w\n1 J\n"}, + {8, "1 0 0 RG\n8 w\n1 J\n"}}; + const AcrobatInk dense_ink = Dense(); + const AcrobatInk uneven_ink = UnevenSegments(); + for (size_t i = 0; i < std::size(kWidths); ++i) { + SCOPED_TRACE(kWidths[i].width); + const float down = 170.0f * i; + ScopedFPDFAnnotation dense = + NewRedInk(page.get(), Moved(dense_ink.strokes, -220, 420 - down), + kWidths[i].width); + ScopedFPDFAnnotation uneven = + NewRedInk(page.get(), Moved(uneven_ink.strokes, 240, 60 - down), + kWidths[i].width); + for (FPDF_ANNOTATION ink : {dense.get(), uneven.get()}) { + EXPECT_THAT(AppearanceOf(ink), HasSubstr(kWidths[i].written)); + } + ExpectSamePath(Moved(PathOf(dense_ink.path), -220, 420 - down), + PathOf(AppearanceOf(dense.get()))); + ExpectSamePath(Moved(PathOf(uneven_ink.path), 240, 60 - down), + PathOf(AppearanceOf(uneven.get()))); + } +} + +// Cell 1E of no-stroke-check.pdf, a zigzag of nine points: every point is +// a turn, so every pair is its own cubic, its handles a third of its length +// and level at each tip. The first cubic is Acrobat's own. +TEST_F(EPDFInkAppearanceEmbedderTest, AZigzagIsOneCubicPerPair) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 612, 792)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation ink = NewRedInk(page.get(), + {{{526, 654}, + {531, 682}, + {536, 654}, + {541, 682}, + {546, 654}, + {551, 682}, + {556, 654}, + {561, 682}, + {566, 654}}}, + 2); + const std::string content = AppearanceOf(ink.get()); + EXPECT_THAT( + content, + HasSubstr("526 654 m\n527.6667 663.3333 521.519 682 531 682 c\n")); + ExpectSamePath(PathOf("526 654 m\n" + "527.6667 663.3333 521.519 682 531 682 c\n" + "540.481 682 526.519 654 536 654 c\n" + "545.481 654 531.519 682 541 682 c\n" + "550.481 682 536.519 654 546 654 c\n" + "555.481 654 541.519 682 551 682 c\n" + "560.481 682 546.519 654 556 654 c\n" + "565.481 654 551.519 682 561 682 c\n" + "570.481 682 564.3333 663.3333 566 654 c\n"), + PathOf(content)); +} diff --git a/fpdfsdk/fpdf_annot_embeddertest.cpp b/fpdfsdk/fpdf_annot_embeddertest.cpp index e5a7dec34..2f99c0f44 100644 --- a/fpdfsdk/fpdf_annot_embeddertest.cpp +++ b/fpdfsdk/fpdf_annot_embeddertest.cpp @@ -580,8 +580,9 @@ TEST_F(FPDFAnnotEmbedderTest, ExtractInkMultiple) { EXPECT_EQ(475.336121f, rect.right); EXPECT_EQ(681.535034f, rect.top); } - // EmbedPDF(own-appearance-generator): our ink appearance strokes with round - // caps and joins and fits its box to the stroke; the dump is ours. + // EmbedPDF(own-appearance-generator): our ink appearance draws Acrobat's + // fitted curves with round caps and fits its box to the stroke; the dump is + // ours. CompareDisplayList(RenderLoadedPageWithFlagsToDisplayList(page, FPDF_ANNOT), "annotation_ink_multiple"); UnloadPageNoEvents(page); diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt index 4186cfc22..bd729339b 100644 --- a/testing/embedpdf_layer_mode_writers.txt +++ b/testing/embedpdf_layer_mode_writers.txt @@ -242,6 +242,10 @@ EPDFFormEmbedderTest.ToggleWidgetsTurnOnTheirOwnState 3 EPDFFormEmbedderTest.TwoPlaneTwinWidgetsFillTogether 9 EPDFFormEmbedderTest.TypedValueSnapshotPreservesPdfShapes 3 EPDFFormEmbedderTest.WidgetRotationTurnsItsAppearance 2 +EPDFInkAppearanceEmbedderTest.AZigzagIsOneCubicPerPair 4 +EPDFInkAppearanceEmbedderTest.DrawsAcrobatsCurves 10 +EPDFInkAppearanceEmbedderTest.OnePointIsAcrobatsDot 4 +EPDFInkAppearanceEmbedderTest.TheCurvesDoNotDependOnTheWidth 11 EPDFMeasureEmbedderTest.BadArgumentsAndRejectedWrites 5 EPDFMeasureEmbedderTest.CaptionAppearanceGolden 19 EPDFMeasureEmbedderTest.DirectMeasureWithSharedNestedFormatDetachesOnlyItsOwner 5 diff --git a/testing/resources/embedder_tests/annotation_ink_multiple.dump.txt b/testing/resources/embedder_tests/annotation_ink_multiple.dump.txt index 0fbf77ecd..e789c7232 100644 --- a/testing/resources/embedder_tests/annotation_ink_multiple.dump.txt +++ b/testing/resources/embedder_tests/annotation_ink_multiple.dump.txt @@ -36,17 +36,17 @@ DrawDeviceText font="Arial" size=14.6667 color=0xff000000 matrix=[0.7500,0.0000, RestoreState SaveState RestoreState -SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(85.8128,668.3480) line(85.8128,702.3980) line(135.8370,702.3980) line(135.8370,668.3480) line(85.8128,668.3480,close)] -DrawPath fill=0x00000000 stroke=0xff00ff00 rule=no-fill stroke_state=[width=2.0000 cap=round join=round miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(86.8128,676.1270) line(87.4285,676.1270) line(88.6598,676.7440) line(88.6598,677.3600) line(89.8912,677.9770) line(89.8912,678.5930) line(90.5068,679.2100) line(91.1225,679.2100) line(91.1225,679.8260) line(91.7382,679.8260) line(91.7382,680.4420) line(92.3539,680.4420) line(92.3539,681.0580) line(92.9695,681.0580) line(93.5858,681.6740) line(94.2015,681.6740) line(95.4328,682.2910) line(95.4328,682.9080) line(96.0485,682.9080) line(96.6642,683.5240) line(97.2798,683.5240) line(97.8955,683.5240) line(98.5112,683.5240) line(99.1269,683.5240) line(99.7425,683.5240) line(100.3580,683.5240) line(100.9740,683.5240) line(101.5900,683.5240) line(102.2050,682.9080) line(102.8210,682.9080) line(103.4370,682.2910) line(104.0520,681.6740) line(105.2840,681.0580) line(106.5150,679.8260) line(107.1310,679.2100) line(107.7460,677.9770) line(108.3620,677.3600) line(108.9780,677.3600) line(108.9780,676.1270) line(109.5930,675.5110) line(109.5930,674.8950) line(109.5930,674.2790) line(109.5930,673.0460) line(109.5930,672.4290) line(109.5930,671.8130) line(109.5930,671.1970) line(109.5930,670.5810) line(109.5930,669.9640) line(108.9780,669.3480) line(108.3620,669.3480) line(107.1310,669.3480) line(106.5150,669.3480) line(105.8990,669.3480) line(105.8990,669.9640) line(105.2840,669.9640) line(104.6680,669.9640) line(104.6680,670.5810) line(104.0520,670.5810) line(103.4370,671.1970) line(102.2050,671.8130) line(102.2050,672.4290) line(101.5900,672.4290) line(101.5900,673.6630) line(100.9740,674.2790) line(100.3580,674.8950) line(100.3580,675.5110) line(100.3580,676.1270) line(99.7425,677.3600) line(99.7425,677.9770) line(99.7425,679.2100) line(99.7425,679.8260) line(99.7425,680.4420) line(100.3580,682.2910) line(100.3580,682.9080) line(100.3580,684.1400) line(100.9740,685.3730) line(101.5900,685.9890) line(102.2050,686.6050) line(102.8210,687.2220) line(102.8210,687.8380) line(103.4370,687.8380) line(104.0520,688.4550) line(104.6680,688.4550) line(105.2840,688.4550) line(106.5150,689.0710) line(107.1310,689.0710) line(107.7460,689.0710) line(108.3620,689.0710) line(108.3620,689.6870) line(108.9780,689.6870) line(109.5930,689.6870) line(110.2090,689.6870) line(110.8250,689.6870) line(111.4410,689.6870) line(111.4410,689.0710) line(112.0570,689.0710) line(112.6720,689.0710) line(113.2880,688.4550) line(113.9040,688.4550) line(114.5190,687.8380) line(115.7510,687.2220) line(116.3660,686.6050) line(116.9820,685.9890) line(117.5980,684.7560) line(118.2130,684.7560) line(118.2130,684.1400) line(118.8290,683.5240) line(119.4450,682.9080) line(119.4450,682.2910) line(120.0600,681.6740) line(120.0600,681.0580) line(120.0600,680.4420) line(120.6760,679.8260) line(120.6760,679.2100) line(121.2920,678.5930) line(121.2920,677.9770) line(121.2920,677.3600) line(121.2920,676.7440) line(121.2920,676.1270) line(121.2920,675.5110) line(121.9070,675.5110) line(121.9070,674.8950) line(121.9070,674.2790) line(121.9070,673.6630) line(121.9070,673.0460) line(121.2920,673.0460) line(120.6760,673.0460) line(120.0600,673.0460) line(119.4450,673.0460) line(118.8290,673.0460) line(118.2130,673.6630) line(117.5980,674.2790) line(116.9820,674.8950) line(116.9820,675.5110) line(116.3660,676.1270) line(116.3660,676.7440) line(115.7510,677.3600) line(115.7510,677.9770) line(115.7510,678.5930) line(115.1350,678.5930) line(115.1350,679.8260) line(115.1350,680.4420) line(115.1350,681.0580) line(115.1350,681.6740) line(115.1350,682.9080) line(115.1350,683.5240) line(115.1350,684.1400) line(115.1350,684.7560) line(115.1350,685.9890) line(115.1350,686.6050) line(115.7510,687.2220) line(115.7510,688.4550) line(116.3660,689.0710) line(116.3660,689.6870) line(116.9820,690.3030) line(116.9820,690.9200) line(117.5980,690.9200) line(117.5980,691.5360) line(117.5980,692.1530) line(118.8290,692.1530) line(119.4450,692.7690) line(120.6760,693.3850) line(121.2920,694.0020) line(121.9070,694.6180) line(122.5230,695.2340) line(123.1390,695.2340) line(123.7540,695.2340) line(123.7540,695.8500) line(124.3700,695.8500) line(124.9860,695.8500) line(125.6010,695.8500) line(126.2170,695.8500) line(126.8330,695.8500) line(127.4480,695.8500) line(128.6800,695.2340) line(129.2960,695.2340) line(130.5270,694.6180) line(130.5270,694.0020) line(131.1430,694.0020) line(131.7590,693.3850) line(132.3740,692.7690) line(132.9900,692.7690) line(132.9900,692.1530) line(133.6060,691.5360) line(133.6060,690.9200) line(134.2210,690.3030) line(134.2210,689.6870) line(134.2210,689.0710) line(134.2210,688.4550) line(134.2210,687.8380) line(134.2210,687.2220) line(134.2210,686.6050) line(134.8370,686.6050) line(134.8370,685.9890) line(134.8370,685.3730) line(134.8370,684.7560) line(134.2210,684.7560) line(134.2210,684.1400) line(134.2210,683.5240) line(133.6060,683.5240) line(133.6060,682.9080) line(132.9900,682.9080) line(132.3740,682.9080) line(131.7590,682.9080) line(131.7590,683.5240) line(131.1430,684.1400) line(130.5270,684.1400) line(130.5270,684.7560) line(129.9120,684.7560) line(129.9120,685.3730) line(128.6800,685.3730) line(128.6800,685.9890) line(128.0650,686.6050) line(127.4480,687.2220) line(127.4480,687.8380) line(126.8330,689.0710) line(126.2170,689.0710) line(126.2170,689.6870) line(126.2170,690.3030) line(126.2170,690.9200) line(125.6010,691.5360) line(125.6010,692.1530) line(125.6010,693.3850) line(125.6010,694.0020) line(125.6010,695.2340) line(125.6010,696.4670) line(125.6010,697.0840) line(126.2170,697.7000) line(126.2170,698.3160) line(126.8330,698.3160) line(126.8330,698.9320) line(126.8330,699.5480) line(127.4480,699.5480) line(128.0650,699.5480) line(128.0650,700.1650) line(128.6800,700.1650) line(128.6800,700.7810) line(129.2960,700.7810) line(129.9120,700.7810) line(130.5270,700.7810) line(131.1430,700.7810) line(131.7590,700.7810) line(131.7590,701.3980) line(132.3740,701.3980) line(132.9900,701.3980) line(133.6060,701.3980) line(134.2210,701.3980)] +SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(85.8128,667.7352) line(85.8128,702.3976) line(135.8107,702.3976) line(135.8107,667.7352) line(85.8128,667.7352,close)] +DrawPath fill=0x00000000 stroke=0xff00ff00 rule=no-fill stroke_state=[width=2.0000 cap=round join=miter miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(86.8128,676.1273) bezier(91.4113,676.1273) bezier(94.4391,687.1021) bezier(101.5896,683.5237) bezier(105.8004,681.4165) bezier(109.5933,677.6286) bezier(109.5933,673.0455) bezier(109.5933,672.0406) bezier(110.0509,669.8848) bezier(108.9776,669.3477) bezier(100.8691,665.2903) bezier(97.5077,682.5200) bezier(102.8209,687.8378) bezier(109.3378,694.3603) bezier(117.3941,685.7789) bezier(120.0603,680.4421) bezier(120.6332,679.2954) bezier(122.9470,674.0874) bezier(121.9073,673.0455) bezier(120.7124,671.8480) bezier(117.5035,673.8509) bezier(116.9820,674.8949) bezier(115.7752,677.3105) bezier(113.4472,683.2270) bezier(115.1349,686.6054) bezier(117.6036,691.5467) bezier(120.0672,692.1603) bezier(123.7544,695.8504) bezier(124.7388,696.8356) bezier(129.8655,695.2804) bezier(130.5274,694.6180) bezier(133.2588,691.8843) bezier(136.6629,685.9675) bezier(133.6057,682.9075) bezier(132.5842,681.8851) bezier(127.8942,686.3300) bezier(127.4484,687.2216) bezier(123.5521,695.0128) bezier(125.8928,701.3976) bezier(134.2214,701.3976)] RestoreState SaveState RestoreState -SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(147.6100,557.3020) line(147.6100,635.4830) line(290.2730,635.4830) line(290.2730,557.3020) line(147.6100,557.3020,close)] -DrawPath fill=0x00000000 stroke=0xffff0000 rule=no-fill stroke_state=[width=6.0000 cap=round join=round miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(166.5470,560.3020) line(166.1480,561.1000) line(164.9530,562.6950) line(163.7580,564.6890) line(161.3670,568.2780) line(159.3750,571.0690) line(156.9840,576.2540) line(154.9920,579.4440) line(153.3980,582.6350) line(151.4060,589.0150) line(150.6100,593.4020) line(150.6100,596.5920) line(150.6100,601.3780) line(150.6100,604.1690) line(152.6020,608.5560) line(154.5940,610.1510) line(156.1870,611.7460) line(158.5780,613.3410) line(161.7660,614.9370) line(167.3440,616.5320) line(171.3280,617.3290) line(175.3120,617.3290) line(182.0860,617.3290) line(187.2660,617.3290) line(194.8360,617.3290) line(199.2190,616.1330) line(203.6010,615.3360) line(209.5780,613.3410) line(212.3670,612.5440) line(215.9530,609.7520) line(217.9450,608.9550) line(218.3440,608.1570) line(219.1410,606.5620) line(219.9380,604.9670) line(220.7340,602.5740) line(221.1330,600.9790) line(221.1330,599.3840) line(221.1330,595.3960) line(221.1330,593.0030) line(220.7340,591.0090) line(219.1410,586.6220) line(217.5470,583.8310) line(214.3590,579.8430) line(211.9690,577.8490) line(209.5780,575.8550) line(205.9920,573.8610) line(203.6010,573.0630) line(200.8120,572.2660) line(199.2190,572.2660) line(198.8200,572.2660) line(197.6250,571.8670) line(196.8280,571.8670) line(196.0310,571.8670) line(195.6330,571.8670) line(195.2340,571.8670) line(194.8360,572.6640) line(194.4370,573.4620) line(193.6400,575.8550) line(192.8440,578.2480) line(192.0470,583.4320) line(191.2500,585.8250) line(191.2500,587.8180) line(191.6480,591.8070) line(192.4450,594.9970) line(194.8360,598.9850) line(196.8280,601.7770) line(198.4220,604.1690) line(201.6090,607.3590) line(205.1950,608.9550) line(207.5860,610.9490) line(212.3670,613.3410) line(215.5550,614.1390) line(221.1330,615.3360) line(224.7190,615.3360) line(229.5000,615.3360) line(232.2890,615.3360) line(233.4840,614.5380) line(235.8750,614.1390) line(236.2740,612.9420) line(237.0700,612.5440) line(238.2660,610.5500) line(239.4610,608.5560) line(241.0550,604.5680) line(241.8510,602.1750) line(241.8510,599.7820) line(241.8510,594.9970) line(241.8510,591.8070) line(241.8510,588.6160) line(240.6560,586.2230) line(240.2580,584.6280) line(238.6640,582.2360) line(237.0700,581.4380) line(235.4770,579.8430) line(234.2810,579.8430) line(233.4840,579.4440) line(232.6870,579.0450) line(232.2890,579.0450) line(231.8900,579.0450) line(231.0940,579.8430) line(229.8980,583.8310) line(229.1020,587.0210) line(227.1100,592.2050) line(227.1100,595.7950) line(226.3130,599.3840) line(226.3130,604.9670) line(226.3130,608.1570) line(226.3130,611.7460) line(229.1020,616.5320) line(231.4920,618.9240) line(235.8750,622.5140) line(238.6640,624.5080) line(241.0550,626.1030) line(246.6330,629.6910) line(250.2190,630.4900) line(257.7890,632.4830) line(262.1720,632.4830) line(265.3600,632.4830) line(271.3360,632.4830) line(274.1250,631.6860) line(277.7110,630.4900) line(279.3050,629.6910) line(281.2970,628.4960) line(282.4920,626.9000) line(283.2890,626.5010) line(284.4850,624.9060) line(285.2810,623.7100) line(285.2810,623.3110) line(286.0780,622.1140) line(286.0780,621.7160) line(286.4770,621.3170) line(286.4770,620.1210) line(286.8750,619.7220) line(286.8750,618.9240) line(287.2730,618.1270) line(287.2730,617.7280) line(287.2730,617.3290) line(287.2730,616.9310) line(287.2730,616.5320) line(287.2730,616.1330) line(287.2730,615.7340) line(287.2730,615.3360)] +SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(146.6938,557.3020) line(146.6938,635.9964) line(290.2734,635.9964) line(290.2734,557.3020) line(146.6938,557.3020,close)] +DrawPath fill=0x00000000 stroke=0xffff0000 rule=no-fill stroke_state=[width=6.0000 cap=round join=miter miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(166.5466,560.3020) bezier(159.9053,573.5969) bezier(146.0085,587.5890) bezier(150.6095,604.1693) bezier(152.6516,611.5280) bezier(160.6363,614.8533) bezier(167.3441,616.5316) bezier(183.0008,620.4491) bezier(204.5477,617.8945) bezier(217.9454,608.9545) bezier(220.0870,607.5255) bezier(221.1327,601.7145) bezier(221.1327,599.3840) bezier(221.1327,589.5718) bezier(218.1850,581.5984) bezier(209.5782,575.8553) bezier(208.2582,574.9745) bezier(197.4461,569.6528) bezier(195.2341,571.8668) bezier(192.8162,574.2868) bezier(192.6351,580.6356) bezier(192.0468,583.4324) bezier(188.1972,601.7355) bezier(208.2405,615.3357) bezier(224.7191,615.3357) bezier(228.0560,615.3357) bezier(232.8178,615.8881) bezier(235.8752,614.1390) bezier(243.2536,609.9178) bezier(243.2649,592.7511) bezier(240.6561,586.2233) bezier(239.9791,584.5293) bezier(234.8666,577.0596) bezier(231.8905,579.0455) bezier(228.6614,581.2001) bezier(227.4781,592.4746) bezier(227.1095,595.7947) bezier(226.6156,600.2442) bezier(224.8181,607.2584) bezier(226.3127,611.7463) bezier(230.0790,623.0561) bezier(247.3134,630.7362) bezier(257.7891,632.4833) bezier(270.9365,634.6759) bezier(287.2734,630.0032) bezier(287.2734,615.3357)] RestoreState SaveState RestoreState -SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(351.1170,583.1280) line(351.1170,682.2370) line(476.0390,682.2370) line(476.0390,583.1280) line(351.1170,583.1280,close)] -DrawPath fill=0x00000000 stroke=0x4c0000ff rule=no-fill stroke_state=[width=3.0000 cap=round join=round miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(414.3750,679.9400) line(410.7890,676.7490) line(407.2030,673.1600) line(400.4300,667.1780) line(396.0470,662.7920) line(392.4610,659.2020) line(384.0940,652.4230) line(379.7110,648.0370) line(375.3280,643.6500) line(367.7580,636.8700) line(364.1720,634.0780) line(358.9920,628.8940) line(356.2030,626.1030) line(353.4140,623.3110) line(352.6170,622.5140) line(352.6170,622.1140) line(352.6170,621.7160) line(353.4140,620.9190) line(355.0080,620.1210) line(363.7730,617.3290) line(370.5470,614.5380) line(377.3200,611.7460) line(394.4530,608.1570) line(403.6170,607.3590) line(422.3440,605.3650) line(432.3050,604.5680) line(441.4690,603.3720) line(457.0080,602.1750) line(462.9840,602.1750) line(471.3510,601.3780) line(474.1410,601.3780) line(474.5390,601.3780) line(474.5390,601.7770) line(472.5470,603.7700) line(468.5620,607.7590) line(464.1800,611.3470) line(459.7970,614.9370) line(449.0390,623.3110) line(442.2650,628.4960) line(427.5240,639.2630) line(419.9530,645.2450) line(411.5860,651.2270) line(396.0470,661.9940) line(389.2740,666.3810) line(377.7190,673.1600) line(372.5390,676.7490) line(368.9530,678.7430) line(364.5700,680.7370) line(363.7730,680.7370) line(363.3750,680.7370) line(363.3750,679.9400) line(364.1720,675.1540) line(365.3670,670.7670) line(368.1560,660.0000) line(370.1490,653.2200) line(372.1410,646.4410) line(376.5230,633.2810) line(378.5160,626.5010) line(382.1020,614.9370) line(384.8900,608.9550) line(387.6800,599.7820) line(389.6720,595.3960) line(390.4690,591.0090) line(391.6640,586.6220) line(392.4610,585.8250) line(392.8590,585.0270) line(393.2580,585.0270) line(393.2580,584.6280) line(393.2580,585.8250) line(394.0550,590.2120) line(394.8510,593.4020) line(396.0470,600.9790) line(397.2420,606.1640) line(398.4380,611.3470) line(401.2270,622.1140) line(403.2190,628.0960) line(406.0080,639.6620) line(408.0000,645.6430) line(409.9920,651.6250) line(411.9840,660.0000) line(412.7810,664.3870) line(413.5780,668.7730) line(413.5780,669.5710) line(413.5780,669.9690) line(413.5780,670.3690) line(413.9760,670.7670) line(413.9760,671.1660) line(413.9760,671.9640) line(414.3750,672.3630) line(414.3750,673.1600) line(414.7730,674.3560) line(415.1720,674.7550) line(415.1720,675.9510) line(415.5700,676.3510) line(415.5700,676.7490)] +SetClip_PathFill rule=winding matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(350.9152,582.9253) line(350.9152,682.4658) line(476.1988,682.4658) line(476.1988,582.9253) line(350.9152,582.9253,close)] +DrawPath fill=0x00000000 stroke=0x4c0000ff rule=no-fill stroke_state=[width=3.0000 cap=round join=miter miter=10.0000] matrix=[1.0000,0.0000,0.0000,-1.0000,0.0000,792.0000] points=[move(414.3748,679.9399) bezier(398.5663,665.8717) bezier(383.8155,650.6469) bezier(367.7575,636.8702) bezier(363.7363,633.4202) bezier(359.9591,629.8619) bezier(356.2030,626.1029) bezier(354.8681,624.7669) bezier(351.5701,623.2878) bezier(352.6173,621.7161) bezier(355.3675,617.5883) bezier(365.9719,616.4236) bezier(370.5470,614.5381) bezier(392.4009,605.5317) bezier(418.1520,605.6300) bezier(441.4687,603.3718) bezier(451.4414,602.4059) bezier(461.3800,602.0903) bezier(471.3514,601.3775) bezier(472.4113,601.3017) bezier(473.7886,600.6254) bezier(474.5393,601.3775) bezier(475.2726,602.1121) bezier(473.2802,603.0359) bezier(472.5466,603.7701) bezier(468.5174,607.8029) bezier(464.2716,611.4006) bezier(459.7968,614.9365) bezier(437.1517,632.8302) bezier(413.9472,651.3481) bezier(389.2736,666.3809) bezier(385.4601,668.7043) bezier(381.5151,670.8085) bezier(377.7191,673.1604) bezier(375.9335,674.2667) bezier(374.3108,675.6206) bezier(372.5391,676.7490) bezier(371.6932,677.2877) bezier(364.0315,682.0516) bezier(363.3750,680.7375) bezier(361.9759,677.9368) bezier(367.9632,660.7072) bezier(368.1559,659.9998) bezier(372.2567,644.9514) bezier(376.7772,629.5925) bezier(382.1016,614.9365) bezier(384.4704,608.4162) bezier(387.5682,602.0136) bezier(389.6720,595.3955) bezier(392.3201,587.0653) bezier(388.4663,594.9449) bezier(391.6641,586.6225) bezier(391.9693,585.8282) bezier(393.2577,583.7773) bezier(393.2577,584.6282) bezier(393.2577,602.5329) bezier(401.3709,622.6469) bezier(406.0075,639.6620) bezier(408.9836,650.5833) bezier(415.5700,665.6674) bezier(415.5700,676.7490)] RestoreState RestoreState diff --git a/testing/resources/embedder_tests/ink_with_color_scheme_agg.png b/testing/resources/embedder_tests/ink_with_color_scheme_agg.png index 570c2ae55..aba14f384 100644 Binary files a/testing/resources/embedder_tests/ink_with_color_scheme_agg.png and b/testing/resources/embedder_tests/ink_with_color_scheme_agg.png differ