diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a441a8f6..ea4219ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A PDF `gs` applies the `/ExtGState` stroke parameters `/LW`, `/LC`, `/LJ`, + `/ML` and `/D`. A producer that sets the line width only there — Canva does — + used to have every stroke drawn at the initial width of 1. + - A manual page break starts a new page box, and prints as `break-before:page`. New `BreakType` and `ParagraphStyle::break_before`/`break_after`, mirrored in the JNI, Apple and Python bindings. Towards #174. diff --git a/src/odr/internal/pdf/pdf_page_extractor.cpp b/src/odr/internal/pdf/pdf_page_extractor.cpp index 37eea0615..dd7ae694e 100644 --- a/src/odr/internal/pdf/pdf_page_extractor.cpp +++ b/src/odr/internal/pdf/pdf_page_extractor.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -335,8 +336,9 @@ build_soft_mask(const SoftMaskDef &def, const Resources &resources, GraphicsState &state, const Logger &logger, std::set &warned, ActiveForms &active); -/// Apply `gs` (ISO 32000-1 8.4.5): fold the named `/ExtGState`'s `ca`/`CA`, -/// `/BM` and `/SMask` into the general state. Other entries are out of scope. +/// Apply `gs` (ISO 32000-1 8.4.5): fold the named `/ExtGState`'s stroke +/// parameters (`/LW`, `/LC`, `/LJ`, `/ML`, `/D`), `ca`/`CA`, `/BM` and +/// `/SMask` into the general state. Other entries are out of scope. /// `/BM` may be an array — an ordered fallback list, so pick the first /// supported name. An absent `/SMask` leaves the mask unchanged; `/None` /// clears it. @@ -349,6 +351,30 @@ void apply_ext_g_state(const std::string &name, const Resources &resources, } const Dictionary &dictionary = it->second.as_dictionary(); GraphicsState::General &general = state.current().general; + // Table 58's aliases for `w`/`J`/`j`/`M`/`d`. A producer that sets the line + // width only here (Canva does) strokes at the initial width of 1 without + // them. + if (dictionary.has_value("LW") && dictionary.at("LW").is_real()) { + general.line_width = dictionary.at("LW").as_real(); + } + if (dictionary.has_value("LC") && dictionary.at("LC").is_integer()) { + general.cap_style = + static_cast(dictionary.at("LC").as_integer()); + } + if (dictionary.has_value("LJ") && dictionary.at("LJ").is_integer()) { + general.join_style = + static_cast(dictionary.at("LJ").as_integer()); + } + if (dictionary.has_value("ML") && dictionary.at("ML").is_real()) { + general.miter_limit = dictionary.at("ML").as_real(); + } + // `/D` is `[[array] phase]`, the two `d` operands in one array. + if (dictionary.has_value("D") && dictionary.at("D").is_array()) { + const Array &dash = dictionary.at("D").as_array(); + if (dash.size() == 2 && dash[0].is_array() && dash[1].is_real()) { + general.dash = {dash[0].as_reals(), dash[1].as_real()}; + } + } if (dictionary.has_value("ca") && dictionary.at("ca").is_real()) { general.fill_alpha = dictionary.at("ca").as_real(); } diff --git a/test/data.cmake b/test/data.cmake index 8458ee947..1cbde600f 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -12,7 +12,7 @@ odr_test_data( odr_test_data( PATH "input/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.git" - REVISION "b71cec155018311c130b98c07ddee5e853db9e26") + REVISION "d0bedc89b67e73a2e4113f96f27d1d1cdf30c69f") odr_test_data( PATH "reference-output/odr-public" @@ -22,4 +22,4 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "9e9cc5859cc2c2c36d973f6a568133e2ecc4ec35") + REVISION "2f5964b9b8a5172aac2e4f0d893c6ccf625f8857") diff --git a/test/src/internal/pdf/pdf_page_extractor.cpp b/test/src/internal/pdf/pdf_page_extractor.cpp index b63bbacf7..a7dd81359 100644 --- a/test/src/internal/pdf/pdf_page_extractor.cpp +++ b/test/src/internal/pdf/pdf_page_extractor.cpp @@ -843,6 +843,50 @@ TEST(PdfPageExtractor, gs_blend_mode_array_picks_first_supported) { EXPECT_EQ(path_at(page, 0).blend_mode, "Multiply"); } +// `/LW` is the `w` operator as an `/ExtGState` entry (ISO 32000-1 Table 58); +// a producer that sets the width only there used to stroke at the initial 1. +TEST(PdfPageExtractor, gs_line_width) { + Resources res; + Dictionary gs; + gs["LW"] = Object(Real{3.5}); + res.ext_g_state["GS1"] = Object(std::move(gs)); + const auto page = + extract_page("/GS1 gs 0 0 m 10 10 l S", res, Logger::null()); + ASSERT_EQ(page.size(), 1); + EXPECT_DOUBLE_EQ(path_at(page, 0).line_width, 3.5); +} + +// `/LC`, `/LJ` and `/ML` alias `J`, `j` and `M`. +TEST(PdfPageExtractor, gs_line_cap_join_and_miter_limit) { + Resources res; + Dictionary gs; + gs["LC"] = Object(Integer{1}); + gs["LJ"] = Object(Integer{2}); + gs["ML"] = Object(Real{4}); + res.ext_g_state["GS1"] = Object(std::move(gs)); + const auto page = + extract_page("/GS1 gs 0 0 m 10 10 l S", res, Logger::null()); + ASSERT_EQ(page.size(), 1); + EXPECT_EQ(path_at(page, 0).line_cap, 1); + EXPECT_EQ(path_at(page, 0).line_join, 2); + EXPECT_DOUBLE_EQ(path_at(page, 0).miter_limit, 4); +} + +// `/D` packs both `d` operands into one array: `[[array] phase]`. +TEST(PdfPageExtractor, gs_dash_pattern) { + Resources res; + Dictionary gs; + gs["D"] = Object(Array(std::vector{ + Object(Array(std::vector{Object(Real{3}), Object(Real{2})})), + Object(Real{1})})); + res.ext_g_state["GS1"] = Object(std::move(gs)); + const auto page = + extract_page("/GS1 gs 0 0 m 10 10 l S", res, Logger::null()); + ASSERT_EQ(page.size(), 1); + EXPECT_EQ(path_at(page, 0).dash_array, (std::vector{3, 2})); + EXPECT_DOUBLE_EQ(path_at(page, 0).dash_phase, 1); +} + // The constant alpha is part of the saved graphics state, so `q`/`Q` scope it. TEST(PdfPageExtractor, gs_alpha_scoped_by_q_Q) { Resources res;