From cad388d24a23d99d523d9640ee76e8aa308eb792 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Tue, 3 Nov 2026 20:56:54 +0200 Subject: [PATCH] Draw no stroke for an empty or missing /C, as Acrobat does A square, circle, polygon, line, polyline or ink whose /C is the empty array (ISO 32000's "no colour; transparent") or left out now draws no stroke. The generator used to stroke both in black whenever the border width was above 0. Checked against Acrobat with annotations that have no appearance stream: - No stroke: the fill alone, still inset half the border width, where the stroke's middle would be. A line keeps only its filled closed endings, sized by its width; ink paints nothing but keeps its points (and /Rect). - /BS /W 0 (or /Border [0 0 0]) on a line, polyline or ink is a hairline (0 w, the thinnest line the device draws), with the endings a 1 pt line has. Ink at width 0 used to get no appearance and a line a 1 pt one. A square, circle or polygon at width 0 still has no outline. - A dimension line with no stroke colour draws its filled endings and caption, without segments or leaders. Every stroke that has a colour is written byte for byte as before. EPDFAnnot_SetNoColor() writes a colour entry as the empty array. EPDFAnnot_ClearColor() removes the entry instead, which Preview draws in black for a stroke; an empty /C is no stroke in Acrobat and Preview alike. Tests: the empty array written and read back as no colour; no stroke for a square, circle and polygon with /C left out and empty, the fill inset; a line's filled endings only; hairlines at width 0 for a line, polyline and ink; ink with no colour paints nothing and keeps its /Rect; the C API list; layer-mode writers. Eight stroke-geometry tests and the measurement tests' line helper built shapes without a /C and now set one. On macOS the full embedder suite and the pixel tests fail the same tests as main. --- .../cpdf_appearancegenerator_common.cpp | 16 ++ .../cpdf_appearancegenerator_internal.h | 21 +- .../cpdf_appearancegenerator_shapes.cpp | 210 ++++++++++------- fpdfsdk/epdf_annot.cpp | 19 ++ fpdfsdk/epdf_annot_embeddertest.cpp | 214 ++++++++++++++++++ fpdfsdk/epdf_c_api_test.c | 1 + fpdfsdk/epdf_measure_embeddertest.cpp | 1 + public/epdf_annot.h | 13 ++ testing/embedpdf_layer_mode_writers.txt | 5 + 9 files changed, 414 insertions(+), 86 deletions(-) diff --git a/core/fpdfdoc/cpdf_appearancegenerator_common.cpp b/core/fpdfdoc/cpdf_appearancegenerator_common.cpp index 05dfbb5440..a61f19161a 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_common.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_common.cpp @@ -519,6 +519,22 @@ ByteString GetColorStringWithDefault(const CPDF_Array* color_array, return GenerateColorAP(default_color, operation); } +// The stroke colour operator for /C, or none when /C names no colour: left +// out, or the empty array ISO 32000 defines as "no colour; transparent". +// Acrobat draws neither with a stroke, so the shape keeps only its fill. +std::optional GetStrokeColorString(const CPDF_Dictionary* dict) { + RetainPtr color_array = + dict->GetArrayFor(pdfium::annotation::kC); + if (!color_array) { + return std::nullopt; + } + const CFX_Color color = fpdfdoc::CFXColorFromArray(*color_array); + if (color.nColorType == CFX_Color::Type::kTransparent) { + return std::nullopt; + } + return GenerateColorAP(color, PaintOperation::kStroke); +} + float GetBorderWidth(const CPDF_Dictionary* dict) { RetainPtr border_style_dict = dict->GetDictFor("BS"); if (border_style_dict) { diff --git a/core/fpdfdoc/cpdf_appearancegenerator_internal.h b/core/fpdfdoc/cpdf_appearancegenerator_internal.h index 70f4e0cbf3..602ee8a49d 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_internal.h +++ b/core/fpdfdoc/cpdf_appearancegenerator_internal.h @@ -157,6 +157,7 @@ ByteString GenerateBorderAP(const CFX_FloatRect& rect, ByteString GetColorStringWithDefault(const CPDF_Array* color_array, const CFX_Color& default_color, PaintOperation operation); +std::optional GetStrokeColorString(const CPDF_Dictionary* dict); float GetBorderWidth(const CPDF_Dictionary* dict); ByteString GetDashPatternString(const CPDF_Dictionary* dict); ByteString GetPaintOperatorString(bool is_stroke_rect, bool is_fill_rect); @@ -260,13 +261,25 @@ bool GenerateStrikeOutAP(APGenerationTarget* target, // cpdf_appearancegenerator_shapes.cpp ByteString ReadLineEndingToken(const CPDF_Array* le, size_t idx); +// `stroked` false fills a closed ending without outlining it: a line with no +// stroke colour keeps only its filled endings. void EmitArrowPath(fxcrt::ostringstream& out, float stroke_w, ArrowStyle style, - bool do_fill); -void EmitCirclePath(fxcrt::ostringstream& out, float stroke_w, bool filled); -void EmitSquarePath(fxcrt::ostringstream& out, float stroke_w, bool filled); -void EmitDiamondPath(fxcrt::ostringstream& out, float stroke_w, bool filled); + bool do_fill, + bool stroked = true); +void EmitCirclePath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked = true); +void EmitSquarePath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked = true); +void EmitDiamondPath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked = true); void EmitButtOrSlashPath(fxcrt::ostringstream& out, float stroke_w, float len_factor); diff --git a/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp b/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp index 25c21dbb20..71d100eaad 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_shapes.cpp @@ -55,6 +55,7 @@ using appearance_generator::GetColorStringWithDefault; using appearance_generator::GetDashPatternString; using appearance_generator::GetPaintOperatorString; using appearance_generator::GetShapeRotationInfo; +using appearance_generator::GetStrokeColorString; using appearance_generator::IsFinitePoint; using appearance_generator::kButtLenFactor; using appearance_generator::kGSDictName; @@ -71,6 +72,14 @@ constexpr float kArrowAngle = FXSYS_PI / 6.0f; constexpr float kArrowLenFactor = 9.0f; +// The operator that paints a closed circle, square or diamond ending. +const char* ClosedEndingOperator(bool filled, bool stroked) { + if (!stroked) { + return "f\n"; + } + return filled ? "B\n" : "S\n"; +} + struct CloudyBorderInfo { bool is_cloudy = false; float intensity = 0; @@ -116,8 +125,11 @@ void GenerateLineEndings(fxcrt::ostringstream& ap, return; } - // Get styles needed for drawing the endings + // Get styles needed for drawing the endings. A hairline (/W 0) still gets + // the endings a 1 pt line has, as Acrobat draws them. const float border_w = GetBorderWidth(annot_dict); + const float ending_w = border_w > 0 ? border_w : 1.0f; + const bool has_stroke = GetStrokeColorString(annot_dict).has_value(); RetainPtr interior_color = annot_dict->GetArrayFor("IC"); const bool has_fill = interior_color && !interior_color->IsEmpty(); @@ -128,6 +140,15 @@ void GenerateLineEndings(fxcrt::ostringstream& ap, ending == CPDF_Annot::LineEnding::kUnknown) { return; } + // Without a stroke colour only a filled closed ending paints. + const bool fillable = ending == CPDF_Annot::LineEnding::kClosedArrow || + ending == CPDF_Annot::LineEnding::kRClosedArrow || + ending == CPDF_Annot::LineEnding::kCircle || + ending == CPDF_Annot::LineEnding::kSquare || + ending == CPDF_Annot::LineEnding::kDiamond; + if (!has_stroke && !(fillable && has_fill)) { + return; + } const float line_angle = atan2(unit_dir.y, unit_dir.x); float final_angle = line_angle; @@ -158,26 +179,27 @@ void GenerateLineEndings(fxcrt::ostringstream& ap, switch (ending) { case CPDF_Annot::LineEnding::kClosedArrow: case CPDF_Annot::LineEnding::kRClosedArrow: - EmitArrowPath(ap, border_w, ArrowStyle::kClosed, has_fill); + EmitArrowPath(ap, ending_w, ArrowStyle::kClosed, has_fill, + has_stroke); break; case CPDF_Annot::LineEnding::kOpenArrow: case CPDF_Annot::LineEnding::kROpenArrow: - EmitArrowPath(ap, border_w, ArrowStyle::kOpen, /*do_fill=*/false); + EmitArrowPath(ap, ending_w, ArrowStyle::kOpen, /*do_fill=*/false); break; case CPDF_Annot::LineEnding::kCircle: - EmitCirclePath(ap, border_w, has_fill); + EmitCirclePath(ap, ending_w, has_fill, has_stroke); break; case CPDF_Annot::LineEnding::kSquare: - EmitSquarePath(ap, border_w, has_fill); + EmitSquarePath(ap, ending_w, has_fill, has_stroke); break; case CPDF_Annot::LineEnding::kDiamond: - EmitDiamondPath(ap, border_w, has_fill); + EmitDiamondPath(ap, ending_w, has_fill, has_stroke); break; case CPDF_Annot::LineEnding::kButt: - EmitButtOrSlashPath(ap, border_w, kButtLenFactor); + EmitButtOrSlashPath(ap, ending_w, kButtLenFactor); break; case CPDF_Annot::LineEnding::kSlash: - EmitButtOrSlashPath(ap, border_w, kSlashLenFactor); + EmitButtOrSlashPath(ap, ending_w, kSlashLenFactor); break; default: break; @@ -361,10 +383,11 @@ bool GenerateDimensionLineAP(APGenerationTarget* target, } fxcrt::ostringstream ap; ap << "/" << kGSDictName << " gs\n"; - auto color = annot->GetArrayFor("C"); + const std::optional stroke_color = GetStrokeColorString(annot); auto interior = annot->GetArrayFor("IC"); - ap << GetColorStringWithDefault(color.Get(), CFX_Color::MakeRGB(0, 0, 0), - PaintOperation::kStroke); + if (stroke_color) { + ap << *stroke_color; + } if (interior && !interior->IsEmpty()) { ap << GetColorStringWithDefault(interior.Get(), {}, PaintOperation::kFill); } @@ -372,7 +395,8 @@ bool GenerateDimensionLineAP(APGenerationTarget* target, WritePoint(ap, from) << " m "; WritePoint(ap, to) << " l S\n"; }; - if (border > 0) { + // Without a stroke colour the lines go and the filled endings stay. + if (border > 0 && stroke_color) { WriteFloat(ap, border) << " w " << GetDashPatternString(annot); if (label.outside_arrows) { const float stub = 20 * border; @@ -394,6 +418,8 @@ bool GenerateDimensionLineAP(APGenerationTarget* target, for (const auto& connector : label.connector) { segment(connector.from, connector.to); } + } + if (border > 0) { GenerateLineEndings(ap, {line.start, line.end}, annot, label.outside_arrows); } @@ -435,7 +461,8 @@ ByteString ReadLineEndingToken(const CPDF_Array* le, size_t idx) { void EmitArrowPath(fxcrt::ostringstream& out, float stroke_w, ArrowStyle style, - bool do_fill) { + bool do_fill, + bool stroked) { const float len = kArrowLenFactor * stroke_w; const float a = kArrowAngle; // 30° const float x = -len * std::cos(a); @@ -449,11 +476,15 @@ void EmitArrowPath(fxcrt::ostringstream& out, // ClosedArrow / RClosedArrow out << "0 0 m " << x << " " << y << " l " << x << " " << -y << " l " - << (do_fill ? "b\n" // fill + stroke - : "h S\n"); // just close and stroke (no fill) + << (!stroked ? "f\n" // fill only + : do_fill ? "b\n" // fill + stroke + : "h S\n"); // just close and stroke (no fill) } -void EmitCirclePath(fxcrt::ostringstream& out, float stroke_w, bool filled) { +void EmitCirclePath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked) { const float r = (stroke_w * 5.0f) / 2.0f; // This is a standard approximation for drawing a circle with Bezier curves. constexpr float kL = 0.5523f; @@ -463,19 +494,27 @@ void EmitCirclePath(fxcrt::ostringstream& out, float stroke_w, bool filled) { << " c " << -d << " " << r << " " << -r << " " << d << " " << -r << " 0 c " << -r << " " << -d << " " << -d << " " << -r << " 0 " << -r << " c " << d << " " << -r << " " << r << " " << -d << " " << r << " 0 c " - << (filled ? "B\n" : "S\n"); + << ClosedEndingOperator(filled, stroked); } -void EmitSquarePath(fxcrt::ostringstream& out, float stroke_w, bool filled) { +void EmitSquarePath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked) { const float h = (stroke_w * 6.0f) / 2.0f; out << -h << " " << -h << " m " << h << " " << -h << " l " << h << " " << h - << " l " << -h << " " << h << " l h " << (filled ? "B\n" : "S\n"); + << " l " << -h << " " << h << " l h " + << ClosedEndingOperator(filled, stroked); } -void EmitDiamondPath(fxcrt::ostringstream& out, float stroke_w, bool filled) { +void EmitDiamondPath(fxcrt::ostringstream& out, + float stroke_w, + bool filled, + bool stroked) { const float h = (stroke_w * 6.0f) / 2.0f; out << "0 " << -h << " m " << h << " 0 l " - << "0 " << h << " l " << -h << " 0 l h " << (filled ? "B\n" : "S\n"); + << "0 " << h << " l " << -h << " 0 l h " + << ClosedEndingOperator(filled, stroked); } void EmitButtOrSlashPath(fxcrt::ostringstream& out, @@ -496,12 +535,14 @@ bool GenerateCircleAP(APGenerationTarget* target, CFX_Color::MakeTransparent(), PaintOperation::kFill); - app_stream << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); + const std::optional stroke_color = + GetStrokeColorString(annot_dict); + if (stroke_color) { + app_stream << *stroke_color; + } float border_width = GetBorderWidth(annot_dict); - bool is_stroke_rect = border_width > 0; + bool is_stroke_rect = stroke_color && border_width > 0; CloudyBorderInfo cloudy_info = GetCloudyBorderInfo(annot_dict); if (is_stroke_rect) { @@ -521,7 +562,9 @@ bool GenerateCircleAP(APGenerationTarget* target, GenerateCloudyEllipsePath(app_stream, draw_rect, cloudy_info.intensity, border_width); } else { - if (is_stroke_rect) { + // The outline's middle sits half its width inside /Rect, painted or not: + // a fill without its stroke keeps the stroke's place, as Acrobat draws it. + if (border_width > 0) { draw_rect.Deflate(border_width / 2, border_width / 2); } @@ -576,12 +619,14 @@ bool GeneratePolygonAP(APGenerationTarget* target, CFX_Color::MakeTransparent(), PaintOperation::kFill); - app << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); + const std::optional stroke_color = + GetStrokeColorString(annot_dict); + if (stroke_color) { + app << *stroke_color; + } const float border_w = GetBorderWidth(annot_dict); - const bool do_stroke = border_w > 0; + const bool do_stroke = stroke_color && border_w > 0; CloudyBorderInfo cloudy_info = GetCloudyBorderInfo(annot_dict); if (do_stroke) { @@ -661,19 +706,18 @@ bool GenerateLineAP(APGenerationTarget* target, ap << GetColorStringWithDefault(interior_color.Get(), {}, PaintOperation::kFill); } - ap << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); - const float border_w = GetBorderWidth(annot_dict); - if (border_w > 0) { - ap << border_w << " w " << GetDashPatternString(annot_dict); + // Draw the main line segment. Width 0 is the thinnest line the device + // draws (ISO 32000 8.4.3.2), the hairline Acrobat gives a line at /W 0. + const std::optional stroke_color = + GetStrokeColorString(annot_dict); + if (stroke_color) { + ap << *stroke_color << std::max(GetBorderWidth(annot_dict), 0.0f) << " w " + << GetDashPatternString(annot_dict); + ap << points[0].x << " " << points[0].y << " m " << points[1].x << " " + << points[1].y << " l S\n"; } - // Draw the main line segment. - ap << points[0].x << " " << points[0].y << " m " << points[1].x << " " - << points[1].y << " l S\n"; - // Draw the endings. GenerateLineEndings(ap, points, annot_dict); @@ -708,21 +752,19 @@ bool GeneratePolyLineAP(APGenerationTarget* target, ap << GetColorStringWithDefault(interior_color.Get(), {}, PaintOperation::kFill); } - ap << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); - const float border_w = GetBorderWidth(annot_dict); - if (border_w > 0) { - ap << border_w << " w " << GetDashPatternString(annot_dict); - } - - // Draw the main polyline path. - ap << points[0].x << " " << points[0].y << " m "; - for (size_t i = 1; i < points.size(); ++i) { - ap << points[i].x << " " << points[i].y << " l "; + // Draw the main polyline path, a hairline at /W 0 as a line's is. + const std::optional stroke_color = + GetStrokeColorString(annot_dict); + if (stroke_color) { + ap << *stroke_color << std::max(GetBorderWidth(annot_dict), 0.0f) << " w " + << GetDashPatternString(annot_dict); + ap << points[0].x << " " << points[0].y << " m "; + for (size_t i = 1; i < points.size(); ++i) { + ap << points[i].x << " " << points[i].y << " l "; + } + ap << "S\n"; } - ap << "S\n"; // Draw the endings. GenerateLineEndings(ap, points, annot_dict); @@ -753,24 +795,23 @@ bool GenerateInkAP(APGenerationTarget* target, return false; } - float border_width = GetBorderWidth(annot_dict); - const bool is_stroke = border_width > 0; - if (!is_stroke) { - return false; - } + // Ink is all stroke: with no stroke colour it paints nothing but keeps its + // points. Width 0 is a hairline, as Acrobat draws it. + const std::optional stroke_color = + GetStrokeColorString(annot_dict); fxcrt::ostringstream app_stream; app_stream << "/" << kGSDictName << " gs "; - app_stream << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); + if (stroke_color) { + app_stream << *stroke_color; - app_stream << border_width << " w "; + 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) + app_stream << "1 J "; // Rounded Line Caps + app_stream << "1 j "; // Rounded Line Joins (for the corners in the middle) - app_stream << GetDashPatternString(annot_dict); + app_stream << GetDashPatternString(annot_dict); + } std::vector points; for (size_t i = 0; i < ink_list->size(); i++) { @@ -780,20 +821,21 @@ bool GenerateInkAP(APGenerationTarget* target, continue; } - const float x0 = coordinates_array->GetFloatAt(0); - const float y0 = coordinates_array->GetFloatAt(1); - app_stream << x0 << " " << y0 << " m "; - points.push_back({x0, y0}); - - // Start loop at the second point (index 2) --- - // The 'm' command already moves to the first point. + 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) { - const float x = coordinates_array->GetFloatAt(j); - const float y = coordinates_array->GetFloatAt(j + 1); - app_stream << x << " " << y << " l "; - points.push_back({x, y}); + 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 "; } - app_stream << "S\n"; } @@ -820,12 +862,14 @@ bool GenerateSquareAP(APGenerationTarget* target, CFX_Color::MakeTransparent(), PaintOperation::kFill); - app_stream << GetColorStringWithDefault( - annot_dict->GetArrayFor(pdfium::annotation::kC).Get(), - CFX_Color::MakeRGB(0, 0, 0), PaintOperation::kStroke); + const std::optional stroke_color = + GetStrokeColorString(annot_dict); + if (stroke_color) { + app_stream << *stroke_color; + } float border_width = GetBorderWidth(annot_dict); - const bool is_stroke_rect = border_width > 0; + const bool is_stroke_rect = stroke_color && border_width > 0; CloudyBorderInfo cloudy_info = GetCloudyBorderInfo(annot_dict); if (is_stroke_rect) { @@ -845,7 +889,9 @@ bool GenerateSquareAP(APGenerationTarget* target, GenerateCloudyRectanglePath(app_stream, draw_rect, cloudy_info.intensity, border_width); } else { - if (is_stroke_rect) { + // The outline's middle sits half its width inside /Rect, painted or not: + // a fill without its stroke keeps the stroke's place, as Acrobat draws it. + if (border_width > 0) { draw_rect.Deflate(border_width / 2, border_width / 2); } app_stream << draw_rect.left << " " << draw_rect.bottom << " " diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index cbbe66308e..f2305e3f5e 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -1208,6 +1208,25 @@ EPDFAnnot_ClearColor(FPDF_ANNOTATION annot, FPDFANNOT_COLORTYPE type) { return true; } +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetNoColor(FPDF_ANNOTATION annot, FPDFANNOT_COLORTYPE type) { + ScopedFPDFAnnotationView annot_view(annot); + RetainPtr dict = + GetMutableAnnotDictFromFPDFAnnotation(annot); + if (!dict) { + return false; + } + + // OverlayColor (OC) is only valid for Redact annotations. + if (type == FPDFANNOT_COLORTYPE_OverlayColor && + FPDFAnnot_GetSubtype(annot) != FPDF_ANNOT_REDACT) { + return false; + } + + dict->SetNewFor(GetColorKeyForType(type)); + return true; +} + FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFAnnot_SetOpacity(FPDF_ANNOTATION annot, float opacity) { ScopedFPDFAnnotationView annot_view(annot); diff --git a/fpdfsdk/epdf_annot_embeddertest.cpp b/fpdfsdk/epdf_annot_embeddertest.cpp index 8a345188ec..27bbfffa1b 100644 --- a/fpdfsdk/epdf_annot_embeddertest.cpp +++ b/fpdfsdk/epdf_annot_embeddertest.cpp @@ -849,6 +849,202 @@ TEST_F(EPDFAnnotEmbedderTest, GenerateInkAppearanceSetsRectToWhatItPaints) { EXPECT_FLOAT_EQ(49.0f, thinner.bottom); } +TEST_F(EPDFAnnotEmbedderTest, SetNoColorWritesAnEmptyColorArray) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 100, 100)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation annot( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_SQUARE)); + ASSERT_TRUE(annot); + CPDF_AnnotContext* context = CPDFAnnotContextFromFPDFAnnotation(annot.get()); + ASSERT_TRUE(context); + + ASSERT_TRUE(FPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 255, 0, + 0, 255)); + ASSERT_TRUE(EPDFAnnot_SetNoColor(annot.get(), FPDFANNOT_COLORTYPE_Color)); + RetainPtr color = context->GetAnnotDict()->GetArrayFor("C"); + ASSERT_TRUE(color); + EXPECT_TRUE(color->IsEmpty()); + + // No colour reads as none, like a /C left out. + unsigned int r = 0; + unsigned int g = 0; + unsigned int b = 0; + EXPECT_FALSE( + EPDFAnnot_GetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, &r, &g, &b)); + + // An overlay colour belongs to a redaction alone. + EXPECT_FALSE( + EPDFAnnot_SetNoColor(annot.get(), FPDFANNOT_COLORTYPE_OverlayColor)); +} + +// Acrobat draws no stroke for a /C left out or empty (ISO 32000: "no colour; +// transparent"). The fill stays where the stroke's middle would be. +TEST_F(EPDFAnnotEmbedderTest, NoStrokeColorKeepsTheFillInTheStrokesPlace) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(page); + + auto make = [&](FPDF_ANNOTATION_SUBTYPE subtype) { + ScopedFPDFAnnotation annot(FPDFPage_CreateAnnot(page.get(), subtype)); + const FS_RECTF rect{/*left=*/10, /*top=*/60, /*right=*/60, /*bottom=*/10}; + EXPECT_TRUE(FPDFAnnot_SetRect(annot.get(), &rect)); + EXPECT_TRUE(FPDFAnnot_SetColor( + annot.get(), FPDFANNOT_COLORTYPE_InteriorColor, 0, 0, 255, 255)); + EXPECT_TRUE(FPDFAnnot_SetBorder(annot.get(), 0, 0, /*border_width=*/4)); + return annot; + }; + + ScopedFPDFAnnotation square = make(FPDF_ANNOT_SQUARE); + ScopedFPDFAnnotation circle = make(FPDF_ANNOT_CIRCLE); + ScopedFPDFAnnotation polygon = make(FPDF_ANNOT_POLYGON); + static constexpr FS_POINTF kVertices[] = {{10, 10}, {60, 10}, {35, 60}}; + ASSERT_TRUE( + EPDFAnnot_SetVertices(polygon.get(), kVertices, std::size(kVertices))); + + for (FPDF_ANNOTATION annot : {square.get(), circle.get(), polygon.get()}) { + // Left out, then empty. + for (bool empty : {false, true}) { + if (empty) { + ASSERT_TRUE(EPDFAnnot_SetNoColor(annot, FPDFANNOT_COLORTYPE_Color)); + } + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(annot)); + const std::wstring look = GetNormalAppearance(annot); + EXPECT_THAT(look, Not(HasSubstr(L"RG"))) << "empty: " << empty; + EXPECT_THAT(look, Not(HasSubstr(L" w "))) << "empty: " << empty; + EXPECT_THAT(look, HasSubstr(L"f\n")) << "empty: " << empty; + } + } + + // The square's fill is the box inset by half the 4 pt width. + EXPECT_THAT(GetNormalAppearance(square.get()), + HasSubstr(L"12 12 46 46 re f\n")); + + // A stroke colour brings the outline back, over the same fill. + ASSERT_TRUE( + EPDFAnnot_SetColor(square.get(), FPDFANNOT_COLORTYPE_Color, 255, 0, 0)); + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(square.get())); + const std::wstring outlined = GetNormalAppearance(square.get()); + EXPECT_THAT(outlined, HasSubstr(L"1 0 0 RG")); + EXPECT_THAT(outlined, HasSubstr(L"12 12 46 46 re b\n")); + + // At width 0 the outline goes and the fill takes the whole box. + ASSERT_TRUE(FPDFAnnot_SetBorder(square.get(), 0, 0, 0)); + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(square.get())); + EXPECT_THAT(GetNormalAppearance(square.get()), + HasSubstr(L"10 10 50 50 re f\n")); +} + +// A line with no stroke colour keeps only its filled endings, sized by its +// width; with no fill either, it paints nothing. +TEST_F(EPDFAnnotEmbedderTest, NoStrokeColorLineKeepsItsFilledEndings) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation line(FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_LINE)); + ASSERT_TRUE(line); + const FS_POINTF start{20, 20}; + const FS_POINTF end{150, 120}; + ASSERT_TRUE(EPDFAnnot_SetLine(line.get(), &start, &end)); + ASSERT_TRUE(EPDFAnnot_SetLineEndings(line.get(), FPDF_ANNOT_LE_OpenArrow, + FPDF_ANNOT_LE_ClosedArrow)); + ASSERT_TRUE(FPDFAnnot_SetBorder(line.get(), 0, 0, 2)); + ASSERT_TRUE(FPDFAnnot_SetColor(line.get(), FPDFANNOT_COLORTYPE_InteriorColor, + 0, 0, 255, 255)); + ASSERT_TRUE(EPDFAnnot_SetNoColor(line.get(), FPDFANNOT_COLORTYPE_Color)); + + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(line.get())); + std::wstring look = GetNormalAppearance(line.get()); + // No segment, no open arrow: nothing is stroked. + EXPECT_THAT(look, Not(HasSubstr(L"S\n"))); + EXPECT_THAT(look, Not(HasSubstr(L"RG"))); + // The closed arrow is filled, 9 times the 2 pt width long. + EXPECT_THAT(look, HasSubstr(L" l f\n")); + EXPECT_THAT(look, HasSubstr(L"-15.5885 9 l -15.5885 -9 l f\n")); + + CPDF_AnnotContext* context = CPDFAnnotContextFromFPDFAnnotation(line.get()); + ASSERT_TRUE(context); + context->GetMutableAnnotDict()->RemoveFor("IC"); + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(line.get())); + look = GetNormalAppearance(line.get()); + EXPECT_THAT(look, Not(HasSubstr(L" f\n"))); + EXPECT_THAT(look, Not(HasSubstr(L"S\n"))); +} + +// Width 0 is the thinnest line the device draws (ISO 32000 8.4.3.2): Acrobat +// gives a line, a polyline and ink a hairline there, with endings the size a +// 1 pt line has. +TEST_F(EPDFAnnotEmbedderTest, WidthZeroDrawsAHairline) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(page); + + ScopedFPDFAnnotation line(FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_LINE)); + ASSERT_TRUE(line); + const FS_POINTF start{20, 20}; + const FS_POINTF end{150, 120}; + ASSERT_TRUE(EPDFAnnot_SetLine(line.get(), &start, &end)); + ASSERT_TRUE(EPDFAnnot_SetLineEndings(line.get(), FPDF_ANNOT_LE_None, + FPDF_ANNOT_LE_OpenArrow)); + + ScopedFPDFAnnotation polyline( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_POLYLINE)); + ASSERT_TRUE(polyline); + static constexpr FS_POINTF kVertices[] = {{20, 20}, {80, 120}, {150, 40}}; + ASSERT_TRUE( + EPDFAnnot_SetVertices(polyline.get(), kVertices, std::size(kVertices))); + + ScopedFPDFAnnotation ink(FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_INK)); + ASSERT_TRUE(ink); + ASSERT_EQ(0, + FPDFAnnot_AddInkStroke(ink.get(), kVertices, std::size(kVertices))); + + for (FPDF_ANNOTATION annot : {line.get(), polyline.get(), ink.get()}) { + ASSERT_TRUE( + FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, 255, 0, 0, 255)); + ASSERT_TRUE(FPDFAnnot_SetBorder(annot, 0, 0, 0)); + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(annot)); + 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")); + } + // 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")); +} + +// Ink is all stroke: with no stroke colour it paints nothing, and its /Rect +// stays on its points. +TEST_F(EPDFAnnotEmbedderTest, NoStrokeColorInkPaintsNothing) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation ink(FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_INK)); + ASSERT_TRUE(ink); + static constexpr FS_POINTF kStroke[] = { + {50.0f, 50.0f}, {80.0f, 90.0f}, {120.0f, 60.0f}}; + ASSERT_EQ(0, FPDFAnnot_AddInkStroke(ink.get(), kStroke, std::size(kStroke))); + ASSERT_TRUE(FPDFAnnot_SetBorder(ink.get(), 0, 0, 6)); + ASSERT_TRUE(EPDFAnnot_SetNoColor(ink.get(), FPDFANNOT_COLORTYPE_Color)); + + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(ink.get())); + const std::wstring look = GetNormalAppearance(ink.get()); + EXPECT_THAT(look, Not(HasSubstr(L"S\n"))); + EXPECT_THAT(look, Not(HasSubstr(L" m "))); + FS_RECTF rect; + ASSERT_TRUE(FPDFAnnot_GetRect(ink.get(), &rect)); + EXPECT_FLOAT_EQ(50.0f, rect.left); + EXPECT_FLOAT_EQ(90.0f, rect.top); + EXPECT_FLOAT_EQ(120.0f, rect.right); + EXPECT_FLOAT_EQ(50.0f, rect.bottom); +} + TEST_F(EPDFAnnotEmbedderTest, LineRectHoldsItsArrowhead) { embedpdf_test::ScopedTestDocument doc(NewTestDocument()); ASSERT_TRUE(doc); @@ -862,6 +1058,8 @@ TEST_F(EPDFAnnotEmbedderTest, LineRectHoldsItsArrowhead) { ASSERT_TRUE(EPDFAnnot_SetLineEndings(annot.get(), FPDF_ANNOT_LE_None, FPDF_ANNOT_LE_ClosedArrow)); ASSERT_TRUE(FPDFAnnot_SetBorder(annot.get(), 0.0f, 0.0f, 2.0f)); + ASSERT_TRUE( + FPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0, 255)); // A /Rect at the two points would clip the arrowhead and the line's width. const FS_RECTF points{/*left=*/100, /*top=*/500, /*right=*/320, /*bottom=*/400}; @@ -893,6 +1091,8 @@ TEST_F(EPDFAnnotEmbedderTest, LineRectIsWhatItsStrokePaints) { const FS_POINTF level_end{300, 400}; ASSERT_TRUE(EPDFAnnot_SetLine(level.get(), &level_start, &level_end)); ASSERT_TRUE(FPDFAnnot_SetBorder(level.get(), 0.0f, 0.0f, 4.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(level.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); ASSERT_TRUE(EPDFAnnot_GenerateAppearance(level.get())); FS_RECTF rect; ASSERT_TRUE(FPDFAnnot_GetRect(level.get(), &rect)); @@ -910,6 +1110,8 @@ TEST_F(EPDFAnnotEmbedderTest, LineRectIsWhatItsStrokePaints) { const FS_POINTF slanted_end{300, 550}; ASSERT_TRUE(EPDFAnnot_SetLine(slanted.get(), &slanted_start, &slanted_end)); ASSERT_TRUE(FPDFAnnot_SetBorder(slanted.get(), 0.0f, 0.0f, 4.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(slanted.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); ASSERT_TRUE(EPDFAnnot_GenerateAppearance(slanted.get())); ASSERT_TRUE(FPDFAnnot_GetRect(slanted.get(), &rect)); EXPECT_NEAR(98.8f, rect.left, 1e-4); @@ -932,6 +1134,8 @@ TEST_F(EPDFAnnotEmbedderTest, PolylineKneePastTheMiterLimitIsBeveled) { ASSERT_TRUE( EPDFAnnot_SetVertices(annot.get(), kVertices, std::size(kVertices))); ASSERT_TRUE(FPDFAnnot_SetBorder(annot.get(), 0.0f, 0.0f, 4.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); ASSERT_TRUE(EPDFAnnot_GenerateAppearance(annot.get())); FS_RECTF rect; ASSERT_TRUE(FPDFAnnot_GetRect(annot.get(), &rect)); @@ -1042,6 +1246,8 @@ TEST_F(EPDFAnnotEmbedderTest, DrawingRectIsWhereTheAppearanceIsDrawn) { ASSERT_TRUE(EPDFAnnot_SetLineEndings(line.get(), FPDF_ANNOT_LE_None, FPDF_ANNOT_LE_ClosedArrow)); ASSERT_TRUE(FPDFAnnot_SetBorder(line.get(), 0.0f, 0.0f, 2.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(line.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); const FS_RECTF tight{/*left=*/100, /*top=*/400, /*right=*/300, /*bottom=*/400}; ASSERT_TRUE(FPDFAnnot_SetRect(line.get(), &tight)); @@ -1100,6 +1306,8 @@ TEST_F(EPDFAnnotEmbedderTest, CloudyPolygonRectHoldsItsBumps) { EPDFAnnot_SetVertices(annot.get(), kVertices, std::size(kVertices))); ASSERT_TRUE(FPDFAnnot_SetBorder(annot.get(), 0.0f, 0.0f, 2.0f)); ASSERT_TRUE(EPDFAnnot_SetBorderEffect(annot.get(), 1.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); const FS_RECTF points{/*left=*/100, /*top=*/250, /*right=*/300, /*bottom=*/100}; ASSERT_TRUE(FPDFAnnot_SetRect(annot.get(), &points)); @@ -5855,6 +6063,8 @@ TEST_F(EPDFAnnotEmbedderTest, CloudyBumpsReachPastTheBoxIntoRect) { ASSERT_TRUE( EPDFAnnot_SetBorderStyle(annot.get(), FPDF_ANNOT_BS_SOLID, 2.0f)); ASSERT_TRUE(EPDFAnnot_SetBorderEffect(annot.get(), 1.0f)); + ASSERT_TRUE( + EPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); ASSERT_TRUE(EPDFAnnot_GenerateAppearance(annot.get())); // /Rect takes in the bumps, and /RD, in rectangle order, leads back to @@ -7142,6 +7352,8 @@ TEST_F(EPDFAnnotEmbedderTest, BorderStyleResolutionUsesISOPrecedence) { ScopedFPDFAnnotation annot( FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_CIRCLE)); ASSERT_TRUE(annot); + ASSERT_TRUE( + EPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); CPDF_AnnotContext* context = CPDFAnnotContextFromFPDFAnnotation(annot.get()); ASSERT_TRUE(context); @@ -7236,6 +7448,8 @@ TEST_F(EPDFAnnotEmbedderTest, DashedBorderDrawsItsDashOrTheDefault) { /*bottom=*/10.0f}; EXPECT_TRUE(FPDFAnnot_SetRect(annot.get(), &rect)); EXPECT_TRUE(EPDFAnnot_SetBorderStyle(annot.get(), style, 2.0f)); + EXPECT_TRUE( + EPDFAnnot_SetColor(annot.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); if (!dash.empty()) { EXPECT_TRUE(EPDFAnnot_SetBorderDashPattern(annot.get(), dash.data(), dash.size())); diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 9e5a57c14a..799c630a80 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -172,6 +172,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFAnnot_SetMKText); CHK(EPDFAnnot_SetMKTextPosition); CHK(EPDFAnnot_SetName); + CHK(EPDFAnnot_SetNoColor); CHK(EPDFAnnot_SetNumberValue); CHK(EPDFAnnot_SetOpacity); CHK(EPDFAnnot_SetOverlayText); diff --git a/fpdfsdk/epdf_measure_embeddertest.cpp b/fpdfsdk/epdf_measure_embeddertest.cpp index 5ceb11cdc7..c925dac5f4 100644 --- a/fpdfsdk/epdf_measure_embeddertest.cpp +++ b/fpdfsdk/epdf_measure_embeddertest.cpp @@ -80,6 +80,7 @@ void MakeLine(FPDF_ANNOTATION annot) { FS_POINTF a{100, 100}, b{300, 100}; ASSERT_TRUE(FPDFAnnot_SetRect(annot, &rect)); ASSERT_TRUE(EPDFAnnot_SetLine(annot, &a, &b)); + ASSERT_TRUE(EPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, 0, 0, 0)); SetText(annot, "Contents", L"3.45 m"); } struct Delta { diff --git a/public/epdf_annot.h b/public/epdf_annot.h index 26a5e44aa9..1e4b87b5e2 100644 --- a/public/epdf_annot.h +++ b/public/epdf_annot.h @@ -291,6 +291,19 @@ EPDFAnnot_GetOpacity(FPDF_ANNOTATION annot, float* opacity); FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFAnnot_ClearColor(FPDF_ANNOTATION annot, FPDFANNOT_COLORTYPE type); +// Experimental EmbedPDF Extension API. +// Write the color of an annotation as an empty array, which ISO 32000 defines +// as no color (transparent). Unlike EPDFAnnot_ClearColor, which removes the +// entry, this states "none" in a way every reader agrees on: a viewer that +// redraws a shape with no /C at all may give it a default color. +// +// annot - handle to an annotation. +// type - type of the color to be set to none. +// +// Returns true if successful. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetNoColor(FPDF_ANNOTATION annot, FPDFANNOT_COLORTYPE type); + // Experimental EmbedPDF Extension API. // Get the border style and width of an annotation. This function handles both // the modern /BS dictionary and the legacy /Border array. diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt index 097e782cef..4186cfc22f 100644 --- a/testing/embedpdf_layer_mode_writers.txt +++ b/testing/embedpdf_layer_mode_writers.txt @@ -114,6 +114,9 @@ EPDFAnnotEmbedderTest.GenerateTextAppearancePerIcon 12 EPDFAnnotEmbedderTest.LineRectHoldsItsArrowhead 4 EPDFAnnotEmbedderTest.LineRectIsWhatItsStrokePaints 5 EPDFAnnotEmbedderTest.ListBoxKoreanUsesRegisteredDroidFallbackFont 20 +EPDFAnnotEmbedderTest.NoStrokeColorInkPaintsNothing 4 +EPDFAnnotEmbedderTest.NoStrokeColorKeepsTheFillInTheStrokesPlace 11 +EPDFAnnotEmbedderTest.NoStrokeColorLineKeepsItsFilledEndings 5 EPDFAnnotEmbedderTest.NoSubsettingFontEmbedsWholeProgram 12 EPDFAnnotEmbedderTest.OpacityIsStoredAsGiven 3 EPDFAnnotEmbedderTest.PolylineKneePastTheMiterLimitIsBeveled 4 @@ -149,6 +152,7 @@ EPDFAnnotEmbedderTest.RichTextWriterFailureLeavesDocumentUntouched 18 EPDFAnnotEmbedderTest.RotatedCaretAppearanceCarriesTransform 3 EPDFAnnotEmbedderTest.SetDefaultAppearanceKeepsFontAtAutoSize 5 EPDFAnnotEmbedderTest.SetNameAcceptsAnyNameAndKeepsAppearance 4 +EPDFAnnotEmbedderTest.SetNoColorWritesAnEmptyColorArray 3 EPDFAnnotEmbedderTest.SetNumberValueRefusesWithoutWriting 3 EPDFAnnotEmbedderTest.SetNumberValueStoresTheNumberAsGiven 3 EPDFAnnotEmbedderTest.SetRichTextJSONRejectsInvalidInputUnchanged 5 @@ -164,6 +168,7 @@ EPDFAnnotEmbedderTest.TurnedBoxRectIsTheUprightBoxAroundIt 2 EPDFAnnotEmbedderTest.VariableFontIsInstancedAtRegistration 12 EPDFAnnotEmbedderTest.WidgetRegisteredFontInstallsRealDrEntry 25 EPDFAnnotEmbedderTest.WidgetTextAlignment 2 +EPDFAnnotEmbedderTest.WidthZeroDrawsAHairline 6 EPDFAnnotRawEmbedderTest.FindsANameOnAnAnnotationWrittenInPlace 3 EPDFAnnotRawEmbedderTest.FindsAnAnnotationByNameOnItsPage 5 EPDFAnnotRawEmbedderTest.FindsAnAnnotationByObjectNumber 5