Skip to content

Commit ce4cf8c

Browse files
committed
Introduce PdfObjectContributor extension mechanism and update to version 0.8.0
- Add `PdfObjectContributor` for external indirect object contributions. - Implement `PdfDocumentContext` as an isolated contributor interface. - Extend `FontRegistry` for indirect font registration. - Add unit tests ensuring deterministic output and contributor execution order. - Add new `ContentStream#showTextHex` method for hex-encoded text rendering.
1 parent d2507b5 commit ce4cf8c

7 files changed

Lines changed: 262 additions & 10 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.offixa</groupId>
88
<artifactId>pdfixa-core</artifactId>
9-
<version>0.7.0</version>
9+
<version>0.8.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>PDFixa Core</name>

src/main/java/io/offixa/pdfixa/core/content/ContentStream.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ public ContentStream showText(String text) {
156156
return this;
157157
}
158158

159+
/**
160+
* Appends {@code <hexString> Tj\n} — show text from a hex-encoded string.
161+
*
162+
* <p>The caller is responsible for providing valid hex digits.
163+
* This method does not affect or share logic with {@link #showText}.
164+
*
165+
* @param hexString hex-encoded byte sequence (without angle brackets)
166+
*/
167+
public ContentStream showTextHex(String hexString) {
168+
buf.append('<').append(hexString).append("> Tj\n");
169+
return this;
170+
}
171+
159172
// ── Graphics operators ─────────────────────────────────────────────────
160173

161174
/**

src/main/java/io/offixa/pdfixa/core/document/FontRegistry.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public final class FontRegistry {
2525

2626
private final Map<String, String> nameToAlias = new LinkedHashMap<>();
2727
private final Map<String, String> aliasToName = new LinkedHashMap<>();
28+
private final Map<String, Integer> indirectFontObjects = new LinkedHashMap<>();
2829
private int nextIndex = 1;
2930

3031
/**
@@ -50,6 +51,46 @@ public String getFontName(String alias) {
5051
return aliasToName.get(alias);
5152
}
5253

54+
/**
55+
* Registers an indirect font whose definition lives in a separate
56+
* indirect PDF object.
57+
*
58+
* <p>Alias generation uses the same {@code nextIndex} counter as
59+
* {@link #getAlias} to preserve deterministic numbering.
60+
*
61+
* @param fontName logical font name
62+
* @param objectNumber the indirect object number holding the font definition
63+
* @return the alias assigned (e.g. {@code "F3"})
64+
*/
65+
public String registerIndirectFont(String fontName, int objectNumber) {
66+
String alias = "F" + nextIndex++;
67+
nameToAlias.put(fontName, alias);
68+
aliasToName.put(alias, fontName);
69+
indirectFontObjects.put(alias, objectNumber);
70+
return alias;
71+
}
72+
73+
/**
74+
* Returns {@code true} if the given alias refers to an indirect font
75+
* (registered via {@link #registerIndirectFont}).
76+
*/
77+
public boolean isIndirect(String alias) {
78+
return indirectFontObjects.containsKey(alias);
79+
}
80+
81+
/**
82+
* Returns the indirect object number for the given alias.
83+
*
84+
* @throws IllegalArgumentException if the alias is not an indirect font
85+
*/
86+
public int getIndirectObjectNumber(String alias) {
87+
Integer objNum = indirectFontObjects.get(alias);
88+
if (objNum == null) {
89+
throw new IllegalArgumentException("not an indirect font alias: " + alias);
90+
}
91+
return objNum;
92+
}
93+
5394
/**
5495
* Returns an unmodifiable, insertion-ordered view of all alias → font-name
5596
* mappings currently held by this registry.

src/main/java/io/offixa/pdfixa/core/document/PdfDocument.java

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public final class PdfDocument {
5252
private final int catalogNum;
5353
private final int pagesNum;
5454
private final List<PdfPage> pages = new ArrayList<>();
55+
private final List<PdfObjectContributor> contributors = new ArrayList<>();
5556
private PdfInfo info;
5657
private boolean saved;
5758

@@ -151,6 +152,23 @@ public PdfImage addPngImage(byte[] pngBytes) {
151152
return img;
152153
}
153154

155+
/**
156+
* Registers a contributor that will be invoked during {@link #save} to
157+
* inject additional indirect objects into the document.
158+
*
159+
* <p>Contributors are executed in registration order, before page
160+
* resource wiring begins.
161+
*
162+
* @param contributor the contributor to register; must not be {@code null}
163+
*/
164+
public void registerContributor(PdfObjectContributor contributor) {
165+
Objects.requireNonNull(contributor, "contributor");
166+
if (saved) {
167+
throw new IllegalStateException("document has already been saved");
168+
}
169+
contributors.add(contributor);
170+
}
171+
154172
/**
155173
* Sets document metadata to be written into the PDF {@code /Info} dictionary.
156174
*
@@ -186,6 +204,13 @@ public void save(OutputStream out) throws IOException {
186204
}
187205
saved = true;
188206

207+
if (!contributors.isEmpty()) {
208+
PdfDocumentContext ctx = new PdfDocumentContext(registry, fontRegistry);
209+
for (PdfObjectContributor c : contributors) {
210+
c.contribute(ctx);
211+
}
212+
}
213+
189214
MessageDigest digest = sha256Digest();
190215
DigestOutputStream digestOut = new DigestOutputStream(out, digest);
191216
PdfWriter writer = new PdfWriter(digestOut);
@@ -323,15 +348,20 @@ private void wirePage(PdfPage page) {
323348
for (String alias : usedFonts) {
324349
if (!firstFont) w.writeSpace();
325350
firstFont = false;
326-
String baseFontName = fontRegistry.getFontName(alias);
327-
w.writeName(alias); w.writeSpace();
328-
w.beginDictionary();
329-
w.writeName("Type"); w.writeSpace(); w.writeName("Font");
330-
w.writeSpace();
331-
w.writeName("Subtype"); w.writeSpace(); w.writeName("Type1");
332-
w.writeSpace();
333-
w.writeName("BaseFont"); w.writeSpace(); w.writeName(baseFontName);
334-
w.endDictionary();
351+
if (fontRegistry.isIndirect(alias)) {
352+
w.writeName(alias); w.writeSpace();
353+
w.writeReference(fontRegistry.getIndirectObjectNumber(alias), 0);
354+
} else {
355+
String baseFontName = fontRegistry.getFontName(alias);
356+
w.writeName(alias); w.writeSpace();
357+
w.beginDictionary();
358+
w.writeName("Type"); w.writeSpace(); w.writeName("Font");
359+
w.writeSpace();
360+
w.writeName("Subtype"); w.writeSpace(); w.writeName("Type1");
361+
w.writeSpace();
362+
w.writeName("BaseFont"); w.writeSpace(); w.writeName(baseFontName);
363+
w.endDictionary();
364+
}
335365
}
336366
w.endDictionary();
337367
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.offixa.pdfixa.core.document;
2+
3+
import io.offixa.pdfixa.core.internal.ObjectRegistry;
4+
import io.offixa.pdfixa.core.writer.PdfSerializable;
5+
6+
import java.util.Objects;
7+
8+
/**
9+
* Facade provided to {@link PdfObjectContributor} implementations during
10+
* {@link PdfDocument#save}.
11+
*
12+
* <p>Exposes only the operations a contributor needs — object allocation,
13+
* body assignment, and indirect font registration — without leaking the
14+
* {@link ObjectRegistry} type or any other internal infrastructure.
15+
*
16+
* <p>Instances are created exclusively by {@link PdfDocument}; the
17+
* constructor is package-private.
18+
*/
19+
public final class PdfDocumentContext {
20+
21+
private final ObjectRegistry registry;
22+
private final FontRegistry fontRegistry;
23+
24+
PdfDocumentContext(ObjectRegistry registry, FontRegistry fontRegistry) {
25+
this.registry = registry;
26+
this.fontRegistry = fontRegistry;
27+
}
28+
29+
/**
30+
* Allocates the next indirect object number.
31+
*
32+
* @return the newly allocated object number
33+
*/
34+
public int allocateObject() {
35+
return registry.allocate();
36+
}
37+
38+
/**
39+
* Assigns the serialization body for a previously allocated object.
40+
*
41+
* @param objNum object number returned by {@link #allocateObject()}
42+
* @param body non-null serializer for the object's content tokens
43+
*/
44+
public void setObjectBody(int objNum, PdfSerializable body) {
45+
Objects.requireNonNull(body, "body");
46+
registry.setBody(objNum, body);
47+
}
48+
49+
/**
50+
* Registers an indirect font whose definition lives in a separate
51+
* indirect object (allocated via {@link #allocateObject()}).
52+
*
53+
* @param fontName logical font name
54+
* @param objectNumber the indirect object number holding the font definition
55+
* @return the alias assigned (e.g. {@code "F3"})
56+
*/
57+
public String registerIndirectFont(String fontName, int objectNumber) {
58+
return fontRegistry.registerIndirectFont(fontName, objectNumber);
59+
}
60+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.offixa.pdfixa.core.document;
2+
3+
/**
4+
* Extension point for external modules to register indirect PDF objects
5+
* without accessing internal packages directly.
6+
*
7+
* <p>Implementations are registered via
8+
* {@link PdfDocument#registerContributor(PdfObjectContributor)} and invoked
9+
* during {@link PdfDocument#save} — before page resource wiring — in
10+
* registration order.
11+
*/
12+
public interface PdfObjectContributor {
13+
void contribute(PdfDocumentContext context);
14+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package io.offixa.pdfixa.core.document;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.ByteArrayOutputStream;
6+
import java.io.IOException;
7+
8+
import static org.junit.jupiter.api.Assertions.*;
9+
10+
/**
11+
* Tests for the {@link PdfObjectContributor} extension mechanism introduced
12+
* in pdfixa-core 0.8.0.
13+
*/
14+
class PdfObjectContributorTest {
15+
16+
@Test
17+
void contributorAllocatesObjectDeterministically() throws IOException {
18+
byte[] run1 = buildWithDummyContributor();
19+
byte[] run2 = buildWithDummyContributor();
20+
21+
assertArrayEquals(run1, run2,
22+
"Document with a contributor must produce bit-for-bit identical output across runs");
23+
}
24+
25+
@Test
26+
void noContributorProducesSameOutputAsPlainDocument() throws IOException {
27+
byte[] withoutContributor = buildPlain();
28+
byte[] withEmptyList = buildPlain();
29+
30+
assertArrayEquals(withoutContributor, withEmptyList,
31+
"Empty contributor list must produce byte-identical output to a plain document");
32+
}
33+
34+
@Test
35+
void contributorExecutionOrderMatchesRegistrationOrder() throws IOException {
36+
StringBuilder order = new StringBuilder();
37+
38+
PdfDocument doc = new PdfDocument();
39+
doc.addPage().getContent()
40+
.beginText()
41+
.setFont("Helvetica", 12)
42+
.moveText(72, 700)
43+
.showText("test")
44+
.endText();
45+
46+
doc.registerContributor(ctx -> order.append("A"));
47+
doc.registerContributor(ctx -> order.append("B"));
48+
doc.registerContributor(ctx -> order.append("C"));
49+
50+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
51+
doc.save(baos);
52+
53+
assertEquals("ABC", order.toString(),
54+
"Contributors must execute in registration order");
55+
}
56+
57+
private static byte[] buildWithDummyContributor() throws IOException {
58+
PdfDocument doc = new PdfDocument();
59+
PdfPage page = doc.addPage();
60+
page.getContent()
61+
.beginText()
62+
.setFont("Helvetica", 12)
63+
.moveText(72, 700)
64+
.showText("contributor test")
65+
.endText();
66+
67+
doc.registerContributor(ctx -> {
68+
int objNum = ctx.allocateObject();
69+
ctx.setObjectBody(objNum, w -> {
70+
w.beginDictionary();
71+
w.writeName("Type"); w.writeSpace(); w.writeName("DummyObj");
72+
w.endDictionary();
73+
});
74+
});
75+
76+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
77+
doc.save(baos);
78+
return baos.toByteArray();
79+
}
80+
81+
private static byte[] buildPlain() throws IOException {
82+
PdfDocument doc = new PdfDocument();
83+
PdfPage page = doc.addPage();
84+
page.getContent()
85+
.beginText()
86+
.setFont("Helvetica", 12)
87+
.moveText(72, 700)
88+
.showText("plain test")
89+
.endText();
90+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
91+
doc.save(baos);
92+
return baos.toByteArray();
93+
}
94+
}

0 commit comments

Comments
 (0)