diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a59f41d..639b7be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A filled pdf form shows what was filled in, and a marked-up one its markup: + an annotation's appearance stream is painted onto the page. Hidden, no-view + and popup annotations stay unpainted. - A scanned pdf page is no longer blank: `JBIG2Decode` images decode in house. MMR/Huffman, refinement and halftone regions still skip the image. - Justified pdf text is spaced as the file asks: word spacing (`Tw`) applies to diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index ebf0c5ac..e4b27123 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1075,6 +1075,22 @@ lift_group_text(std::vector elements) { return result; } +/// A page's content stream, then the annotation appearances painting on top of +/// it (ISO 32000-1 12.5.5). +std::vector page_elements(const pdf::Page &page, + const std::string &content, + const Logger &logger) { + std::vector elements = + lift_group_text(pdf::extract_page(content, *page.resources, logger)); + for (const pdf::Annotation *annotation : page.annotations) { + std::vector appearance = + lift_group_text(pdf::extract_annotation(*annotation, logger)); + elements.insert(elements.end(), std::make_move_iterator(appearance.begin()), + std::make_move_iterator(appearance.end())); + } + return elements; +} + /// Deduplicates CSS declarations into atomic, single-property classes named /// `` in first-seen order, emitted once in ``. The same font /// sizes, offsets and spacings recur across up to millions of positioned @@ -1409,8 +1425,8 @@ class HtmlServiceImpl final : public HtmlService { /// font-size of the previous element, for its line's trailing space double sel_prev_font_size_pt = 0; - for (const pdf::PageElement &element : lift_group_text( - pdf::extract_page(stream, *page->resources, m_logger))) { + for (const pdf::PageElement &element : + page_elements(*page, stream, m_logger)) { if (handle_graphic_element( element, to_box, width, height, clips, gradients, patterns, masks, m_logger, [&] { vis_close_line(); }, @@ -1870,8 +1886,8 @@ class HtmlServiceImpl final : public HtmlService { // already-decoded stream is cheaper than buffering every page's elements. for (std::size_t pi = 0; pi < pages.size(); ++pi) { const pdf::Page &page = *pages[pi]; - for (const pdf::PageElement &element : lift_group_text(pdf::extract_page( - page_streams[pi], *page.resources, m_logger))) { + for (const pdf::PageElement &element : + page_elements(page, page_streams[pi], m_logger)) { const auto *text = std::get_if(&element); if (text == nullptr || text->text.empty() || text->font == nullptr) { continue; @@ -1945,8 +1961,8 @@ class HtmlServiceImpl final : public HtmlService { double prev_font_pt = 0; const auto close_line = [&] { cur_line = -1; }; - for (const pdf::PageElement &element : lift_group_text(pdf::extract_page( - page_streams[pi], *page.resources, m_logger))) { + for (const pdf::PageElement &element : + page_elements(page, page_streams[pi], m_logger)) { if (handle_graphic_element( element, to_box, width, height, clips, gradients, patterns, masks, m_logger, [&] { close_line(); }, diff --git a/src/odr/internal/pdf/AGENTS.md b/src/odr/internal/pdf/AGENTS.md index 69343d67..132c0757 100644 --- a/src/odr/internal/pdf/AGENTS.md +++ b/src/odr/internal/pdf/AGENTS.md @@ -184,6 +184,14 @@ Things the code won't shout at you: dictionaries (no parser/IR change): `/URI` → external ``, `/GoTo`/`/Dest` → internal `#pN` (each page `div` carries `id="pN"`), named dests via `/Dests` + the `/Names` name tree (depth-guarded). +- **Annotation appearances** paint after the page content (12.5.5): the parser + resolves `/AP /N` — through `/AS` when it is a dictionary of states — into a + form XObject and the matrix fitting its `/Matrix`-transformed `/BBox` onto + `/Rect`; `extract_annotation` then runs it like a `Do`, so a filled form + field's value and a markup annotation's drawing are ordinary elements, text + included (and selectable). Hidden/NoView (`/F`) and popup annotations paint + nothing. AcroForm *interactivity* stays out of scope: the appearance is what + the writer left in the file, never regenerated from `/V` and `/DA`. - **CMYK is naive (no ICC); overprint ignored.** CIE/ICCBased/Indexed/Separation/ DeviceN/Lab resolve to RGB at emission by sampling the tint `/Function` (types 0/2/3/4). @@ -265,9 +273,6 @@ implementation. Grow the corpus alongside (odr-public fixtures + the PDF101 Link annotations (`/URI` + internal `/GoTo`) already land. Remaining: -- **Annotation appearances**: render `/AP` streams (form XObjects again) for - highlights/stamps/form-field appearances; AcroForm *interactivity* stays out of - scope (read-only). - **Remote/launch actions** (`/GoToR`, `/Launch`) and destination scroll position/zoom (the internal-link handler uses only the target page). - **Link overlays vs. text selection**: `` overlays sit above `.sel` text, so diff --git a/src/odr/internal/pdf/pdf_document_element.hpp b/src/odr/internal/pdf/pdf_document_element.hpp index 569cf22f..b8512e88 100644 --- a/src/odr/internal/pdf/pdf_document_element.hpp +++ b/src/odr/internal/pdf/pdf_document_element.hpp @@ -83,7 +83,17 @@ struct Page final : Element { std::vector contents_reference; }; -struct Annotation final : Element {}; +struct Annotation final : Element { + /// The normal appearance to paint (`/AP /N`, 12.5.5), resolved through + /// `/AS` when `/N` is a dictionary of states. Null when there is none, the + /// annotation is hidden, or the appearance is not a form. + XObject *appearance{nullptr}; + /// Places `appearance`'s `/Matrix`-transformed `/BBox` onto `/Rect`; the + /// form's own `/Matrix` concatenates onto this, as it would at `Do`. + util::math::Transform2D appearance_transform; + /// `/CA` (12.5.2), the opacity the whole appearance composites at. + double appearance_alpha{1}; +}; /// A resource dictionary (ISO 32000-1 7.8.3). Every subdictionary is resolved /// eagerly at parse time so extraction needs no parser handle. Element pointers diff --git a/src/odr/internal/pdf/pdf_document_parser.cpp b/src/odr/internal/pdf/pdf_document_parser.cpp index 51cf7c14..79755332 100644 --- a/src/odr/internal/pdf/pdf_document_parser.cpp +++ b/src/odr/internal/pdf/pdf_document_parser.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -1236,20 +1237,125 @@ Resources *parse_resources(State &state, const Object &object) { return resources; } -Annotation *parse_annotation(Document &document, const Dictionary &dictionary) { - auto *annotation = document.create_element(); - annotation->object = Object(dictionary); - return annotation; +/// 12.5.5: fit the appearance's `/Matrix`-transformed `/BBox` onto `/Rect`. A +/// degenerate box or rect yields the identity. +util::math::Transform2D +fit_appearance_to_rect(const std::array &bbox, + const util::math::Transform2D &matrix, + const std::vector &rect) { + if (rect.size() < 4) { + return {}; + } + const std::array, 4> corners{ + matrix.apply(bbox[0], bbox[1]), matrix.apply(bbox[2], bbox[1]), + matrix.apply(bbox[2], bbox[3]), matrix.apply(bbox[0], bbox[3])}; + double x_min = corners[0][0]; + double x_max = corners[0][0]; + double y_min = corners[0][1]; + double y_max = corners[0][1]; + for (const std::array &corner : corners) { + x_min = std::min(x_min, corner[0]); + x_max = std::max(x_max, corner[0]); + y_min = std::min(y_min, corner[1]); + y_max = std::max(y_max, corner[1]); + } + + const double rect_x0 = std::min(rect[0], rect[2]); + const double rect_y0 = std::min(rect[1], rect[3]); + const double rect_width = std::abs(rect[2] - rect[0]); + const double rect_height = std::abs(rect[3] - rect[1]); + const double box_width = x_max - x_min; + const double box_height = y_max - y_min; + const double sx = box_width > 0 ? rect_width / box_width : 1; + const double sy = box_height > 0 ? rect_height / box_height : 1; + + return {sx, 0, 0, sy, rect_x0 - sx * x_min, rect_y0 - sy * y_min}; } -Annotation *parse_annotation(const State &state, - const ObjectReference &reference) { +/// 12.5.5: `/AP /N` is either the form itself or a dictionary of states that +/// `/AS` selects (a check box's `/Off` and `/Yes`). +void parse_annotation_appearance(State &state, const Dictionary &dictionary, + Annotation &annotation) { DocumentParser &parser = state.parser(); - Document &document = state.document(); - IndirectObject object = parser.read_object(reference); + // A popup paints only while its parent is open, a viewer state we do not + // have (12.5.6.14). + if (parser.resolve_object_copy(dictionary.get("Subtype")).as_name_opt() == + "Popup") { + return; + } + // `/F` (12.5.3): Hidden (bit 2) and NoView (bit 6) keep it off the page. + if (dictionary.has_key("F")) { + const Object flags = parser.resolve_object_copy(dictionary["F"]); + if (flags.is_integer() && (flags.as_integer() & 0x22) != 0) { + return; + } + } + if (!dictionary.has_key("AP")) { + return; + } + const Object appearances = parser.resolve_object_copy(dictionary["AP"]); + if (!appearances.is_dictionary() || + !appearances.as_dictionary().has_key("N")) { + return; + } + + Object normal = appearances.as_dictionary()["N"]; + const Object resolved = parser.resolve_object_copy(normal); + if (!resolved.is_dictionary()) { + return; + } + // A stream carries `/BBox`; anything else is the state sub-dictionary. + if (!resolved.as_dictionary().has_key("BBox")) { + const Object as = parser.resolve_object_copy(dictionary.get("AS")); + if (!as.is_name() || !resolved.as_dictionary().has_key(as.as_name())) { + return; // no state in force names an appearance + } + normal = resolved.as_dictionary()[as.as_name()]; + } + + XObject *appearance = parse_x_object(state, normal, nullptr); + if (appearance == nullptr || appearance->subtype != XObject::Subtype::form || + !appearance->bbox.has_value()) { + return; + } + // Without a `/Rect` there is nowhere to put it (12.5.2 requires one). + // `/Rect` corners are commonly indirect, and one resolve reaches only the + // array. + Object rect = parser.resolve_object_copy(dictionary.get("Rect")); + parser.deep_resolve_object(rect); + if (!rect.is_array()) { + return; + } + std::vector corners; + for (const Object &corner : rect.as_array()) { + const std::optional value = corner.as_real_opt(); + if (!value.has_value()) { + return; + } + corners.push_back(*value); + } + + annotation.appearance = appearance; + annotation.appearance_transform = + fit_appearance_to_rect(*appearance->bbox, appearance->matrix, corners); + if (const std::optional alpha = + parser.resolve_object_copy(dictionary.get("CA")).as_real_opt()) { + annotation.appearance_alpha = std::clamp(*alpha, 0.0, 1.0); + } +} + +Annotation *parse_annotation(State &state, const Dictionary &dictionary) { + auto *annotation = state.document().create_element(); + annotation->object = Object(dictionary); + parse_annotation_appearance(state, dictionary, *annotation); + return annotation; +} + +Annotation *parse_annotation(State &state, const ObjectReference &reference) { + IndirectObject object = state.parser().read_object(reference); Annotation *annotation = - parse_annotation(document, object.object.as_dictionary()); + parse_annotation(state, object.object.as_dictionary()); annotation->object_reference = reference; return annotation; } @@ -1301,7 +1407,7 @@ Page *parse_page(State &state, const ObjectReference &reference, Pages *parent, parse_annotation(state, annotation.as_reference())); } else if (annotation.is_dictionary()) { page->annotations.push_back( - parse_annotation(document, annotation.as_dictionary())); + parse_annotation(state, annotation.as_dictionary())); } } } diff --git a/src/odr/internal/pdf/pdf_page_extractor.cpp b/src/odr/internal/pdf/pdf_page_extractor.cpp index c75362e3..b35437b3 100644 --- a/src/odr/internal/pdf/pdf_page_extractor.cpp +++ b/src/odr/internal/pdf/pdf_page_extractor.cpp @@ -1033,6 +1033,50 @@ std::vector pdf::extract_page(const std::string &content, return result; } +std::vector +pdf::extract_annotation(const Annotation &annotation, const Logger &logger) { + if (annotation.appearance == nullptr) { + return {}; + } + const XObject &appearance = *annotation.appearance; + + std::vector result; + std::set warned; + ActiveForms active; + MarkedContentStack marked; + std::optional pen; + GraphicsState state; + + // A form invocation with no enclosing content: the fit-to-`/Rect` matrix, + // then the form's own `/Matrix`, then its `/BBox` clip. The appearance joins + // the active set so a `Do` back to it is caught like any other cycle. + active.insert(&appearance); + state.concat_matrix(annotation.appearance_transform); + state.concat_matrix(appearance.matrix); + if (appearance.bbox.has_value()) { + const std::array &box = *appearance.bbox; + state.clip_bounding_box(box[0], box[1], box[2], box[3]); + } + + const Resources fallback; + run_content(appearance.content, + appearance.resources != nullptr ? *appearance.resources + : fallback, + state, result, logger, warned, active, marked, pen); + + // `/CA` applies to the appearance as a whole (12.5.2), so it composites as a + // group; folding it onto each element would show the overlaps through. + if (annotation.appearance_alpha < 1 && !result.empty()) { + auto children = std::make_shared(); + children->elements = std::move(result); + GroupElement group; + group.children = std::move(children); + group.alpha = annotation.appearance_alpha; + return {PageElement{std::move(group)}}; + } + return result; +} + std::vector pdf::extract_text(const std::string &content, const Resources &resources, const Logger &logger) { diff --git a/src/odr/internal/pdf/pdf_page_extractor.hpp b/src/odr/internal/pdf/pdf_page_extractor.hpp index e9a73533..4de0b2bd 100644 --- a/src/odr/internal/pdf/pdf_page_extractor.hpp +++ b/src/odr/internal/pdf/pdf_page_extractor.hpp @@ -10,6 +10,7 @@ class Logger; namespace odr::internal::pdf { +struct Annotation; struct Resources; /// Execute a page's (decoded, concatenated) content stream and collect the text @@ -33,4 +34,10 @@ std::vector extract_page(const std::string &content, const Resources &resources, const Logger &logger); +/// Execute an annotation's normal appearance stream (ISO 32000-1 12.5.5), +/// returning what it paints in the page's own space. `/CA` under 1 wraps the +/// result in a group. Empty when there is no appearance to paint. +std::vector extract_annotation(const Annotation &annotation, + const Logger &logger); + } // namespace odr::internal::pdf diff --git a/test/data.cmake b/test/data.cmake index a3d86b80..ac2734e8 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -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 "b94a5d394c217fb558da5b29e0862006aa75d54d") + REVISION "38975babdef0e35bc8fcb7d6f5c50d018740e7b7") diff --git a/test/src/internal/pdf/pdf_page_extractor.cpp b/test/src/internal/pdf/pdf_page_extractor.cpp index b063edaf..745dce86 100644 --- a/test/src/internal/pdf/pdf_page_extractor.cpp +++ b/test/src/internal/pdf/pdf_page_extractor.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -47,6 +48,57 @@ Font simple_font(int first_char, std::vector widths) { // `Td` places the origin via the text line matrix; the font size is carried // separately, not folded into the transform. +// An annotation paints its appearance stream fitted onto its `/Rect` +// (12.5.5): the appearance's `/BBox` maps onto the rectangle, so content at +// the box's origin lands at the rectangle's, scaled by their size ratio. +TEST(PdfPageExtractor, annotation_appearance_fits_the_rect) { + XObject appearance; + appearance.subtype = XObject::Subtype::form; + appearance.bbox = std::array{0, 0, 10, 20}; + appearance.content = "BT /F1 12 Tf 1 0 0 1 0 0 Tm (Hi) Tj ET"; + + Annotation annotation; + annotation.appearance = &appearance; + // The rect is twice as wide and half as tall as the box, at (100, 700). + annotation.appearance_transform = Transform2D{2, 0, 0, 0.5, 100, 700}; + + const std::vector elements = + extract_annotation(annotation, Logger::null()); + ASSERT_EQ(elements.size(), 1); + const auto &text = std::get(elements[0]); + EXPECT_DOUBLE_EQ(text.transform.e, 100); + EXPECT_DOUBLE_EQ(text.transform.f, 700); + EXPECT_DOUBLE_EQ(text.transform.a, 2); + EXPECT_DOUBLE_EQ(text.transform.d, 0.5); +} + +// An annotation with no appearance to paint contributes nothing. +TEST(PdfPageExtractor, annotation_without_appearance_paints_nothing) { + Annotation annotation; + EXPECT_TRUE(extract_annotation(annotation, Logger::null()).empty()); +} + +// The appearance's own `/Matrix` concatenates onto the fit, exactly as a form +// XObject's does at `Do`. +TEST(PdfPageExtractor, annotation_appearance_applies_its_matrix) { + XObject appearance; + appearance.subtype = XObject::Subtype::form; + appearance.bbox = std::array{0, 0, 10, 10}; + appearance.matrix = Transform2D::translation(5, 3); + appearance.content = "BT /F1 12 Tf 1 0 0 1 0 0 Tm (Hi) Tj ET"; + + Annotation annotation; + annotation.appearance = &appearance; + annotation.appearance_transform = Transform2D::translation(100, 700); + + const std::vector elements = + extract_annotation(annotation, Logger::null()); + ASSERT_EQ(elements.size(), 1); + const auto &text = std::get(elements[0]); + EXPECT_DOUBLE_EQ(text.transform.e, 105); + EXPECT_DOUBLE_EQ(text.transform.f, 703); +} + TEST(PdfPageExtractor, td_translation) { const auto texts = run("BT /F1 12 Tf 100 700 Td (Hi) Tj ET"); ASSERT_EQ(texts.size(), 1);