Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 28 additions & 2 deletions src/odr/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include <array>
#include <cmath>
#include <cstdint>
#include <memory>
#include <optional>
#include <ranges>
Expand Down Expand Up @@ -335,8 +336,9 @@ build_soft_mask(const SoftMaskDef &def, const Resources &resources,
GraphicsState &state, const Logger &logger,
std::set<std::string> &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.
Expand All @@ -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<std::int32_t>(dictionary.at("LC").as_integer());
}
if (dictionary.has_value("LJ") && dictionary.at("LJ").is_integer()) {
general.join_style =
static_cast<std::int32_t>(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();
}
Expand Down
4 changes: 2 additions & 2 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
44 changes: 44 additions & 0 deletions test/src/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>{
Object(Array(std::vector<Object>{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<double>{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;
Expand Down
Loading