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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,22 @@ lift_group_text(std::vector<pdf::PageElement> 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<pdf::PageElement> page_elements(const pdf::Page &page,
const std::string &content,
const Logger &logger) {
std::vector<pdf::PageElement> elements =
lift_group_text(pdf::extract_page(content, *page.resources, logger));
for (const pdf::Annotation *annotation : page.annotations) {
std::vector<pdf::PageElement> 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
/// `<prefix><n>` in first-seen order, emitted once in `<head>`. The same font
/// sizes, offsets and spacings recur across up to millions of positioned
Expand Down Expand Up @@ -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(); },
Expand Down Expand Up @@ -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<pdf::TextElement>(&element);
if (text == nullptr || text->text.empty() || text->font == nullptr) {
continue;
Expand Down Expand Up @@ -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(); },
Expand Down
11 changes: 8 additions & 3 deletions src/odr/internal/pdf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ Things the code won't shout at you:
dictionaries (no parser/IR change): `/URI` → external `<a>`, `/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).
Expand Down Expand Up @@ -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**: `<a>` overlays sit above `.sel` text, so
Expand Down
12 changes: 11 additions & 1 deletion src/odr/internal/pdf/pdf_document_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,17 @@ struct Page final : Element {
std::vector<ObjectReference> 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
Expand Down
126 changes: 116 additions & 10 deletions src/odr/internal/pdf/pdf_document_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include <odr/internal/pdf/pdf_image.hpp>
#include <odr/internal/util/stream_util.hpp>

#include <algorithm>
#include <cctype>
#include <istream>
#include <memory>
Expand Down Expand Up @@ -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>();
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<double, 4> &bbox,
const util::math::Transform2D &matrix,
const std::vector<double> &rect) {
if (rect.size() < 4) {
return {};
}
const std::array<std::array<double, 2>, 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<double, 2> &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<double> corners;
for (const Object &corner : rect.as_array()) {
const std::optional<Real> 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<Real> 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>();
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;
}
Expand Down Expand Up @@ -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()));
}
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/odr/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,50 @@ std::vector<pdf::PageElement> pdf::extract_page(const std::string &content,
return result;
}

std::vector<pdf::PageElement>
pdf::extract_annotation(const Annotation &annotation, const Logger &logger) {
if (annotation.appearance == nullptr) {
return {};
}
const XObject &appearance = *annotation.appearance;

std::vector<PageElement> result;
std::set<std::string> warned;
ActiveForms active;
Comment thread
andiwand marked this conversation as resolved.
MarkedContentStack marked;
std::optional<Pen> pen;
GraphicsState state;
Comment thread
andiwand marked this conversation as resolved.

// 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<double, 4> &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<GroupChildren>();
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::TextElement> pdf::extract_text(const std::string &content,
const Resources &resources,
const Logger &logger) {
Expand Down
7 changes: 7 additions & 0 deletions src/odr/internal/pdf/pdf_page_extractor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,4 +34,10 @@ std::vector<PageElement> 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<PageElement> extract_annotation(const Annotation &annotation,
const Logger &logger);

} // namespace odr::internal::pdf
2 changes: 1 addition & 1 deletion test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading
Loading