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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {

allprojects {
group = "org.grimmory"
version = "0.9.0"
version = "0.10.0"

repositories {
mavenCentral()
Expand Down
76 changes: 37 additions & 39 deletions src/main/java/org/grimmory/pdfium4j/PdfDocument.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ public final class PdfDocument implements AutoCloseable {
private final Thread ownerThread;
private final List<PdfPage> openPages;
private volatile boolean closed = false;
private volatile boolean structurallyModified = false;
private final Map<MetadataTag, String> pendingMetadata = new LinkedHashMap<>();
private String pendingXmpMetadata = null;

Expand Down Expand Up @@ -429,7 +428,7 @@ public PdfPage page(int index) {
ownerThread,
policy.maxRenderPixels(),
() -> unregisterPage(holder[0]),
() -> structurallyModified = true);
() -> {});
holder[0] = page;
registerPage(page);
return page;
Expand Down Expand Up @@ -686,7 +685,6 @@ public void deletePage(int pageIndex) {
}
try {
EditBindings.FPDFPage_Delete.invokeExact(handle, pageIndex);
structurallyModified = true;
} catch (Throwable t) {
throw new PdfiumException("Failed to delete page " + pageIndex, t);
}
Expand Down Expand Up @@ -717,7 +715,6 @@ public void insertBlankPage(int pageIndex, PageSize size) {
} finally {
ViewBindings.FPDF_ClosePage.invokeExact(pageSeg);
}
structurallyModified = true;
} catch (PdfiumException e) {
throw e;
} catch (Throwable t) {
Expand Down Expand Up @@ -754,7 +751,6 @@ public void importPages(PdfDocument source, String pageRange, int insertIndex) {
if (ok == 0) {
throw new PdfiumException("FPDF_ImportPages failed for range: " + pageRange);
}
structurallyModified = true;
} catch (PdfiumException e) {
throw e;
} catch (Throwable t) {
Expand Down Expand Up @@ -859,6 +855,14 @@ public Optional<String> metadata(MetadataTag tag) {
return (value == null || value.isEmpty()) ? Optional.empty() : Optional.of(value);
}

return readNativeMetadata(tag);
}

/**
* Read a metadata value directly from the native PDFium document handle, bypassing the pending
* metadata cache. Used internally to merge existing metadata with pending changes during save.
*/
private Optional<String> readNativeMetadata(MetadataTag tag) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment tagSeg = arena.allocateFrom(tag.pdfKey());

Expand Down Expand Up @@ -1231,19 +1235,14 @@ public List<Bookmark> bookmarks() {
/**
* Save the document to a file.
*
* <p>When only metadata has been modified (no page additions, deletions, or imports), this uses a
* fast path that reads the original PDF bytes and appends an incremental update - avoiding the
* expensive native {@code FPDF_SaveAsCopy} serialization entirely.
* <p>Always serializes through PDFium's native {@code FPDF_SaveAsCopy} to guarantee a
* structurally valid PDF. Metadata changes (Info dictionary, XMP) are applied as a validated
* incremental update on top of the native output.
*
* @param path output file path
*/
public void save(Path path) {
byte[] bytes;
if (structurallyModified) {
bytes = saveToBytes();
} else {
bytes = saveMetadataOnly();
}
byte[] bytes = saveToBytes();
try {
Files.write(path, bytes);
} catch (IOException e) {
Expand All @@ -1258,31 +1257,35 @@ public void save(Path path) {
*/
public byte[] saveToBytes() {
ensureOpen();
return PdfSaver.saveToBytes(handle, pendingMetadata, pendingXmpMetadata);
Map<MetadataTag, String> mergedMetadata = buildMergedMetadata();
return PdfSaver.saveToBytes(handle, mergedMetadata, pendingXmpMetadata);
}

/**
* Fast metadata-only save: reads original bytes and appends an incremental update with the
* pending Info Dictionary and/or XMP changes. Avoids the expensive {@code FPDF_SaveAsCopy} native
* serialization.
* Build a complete Info dictionary by merging existing metadata (read from PDFium) with pending
* changes. This ensures that setting a single tag (e.g., Title) does not discard other existing
* entries (e.g., Author, Subject) when the incremental update replaces the Info dictionary.
*/
private byte[] saveMetadataOnly() {
ensureOpen();
byte[] original;
if (sourceBytes != null) {
original = sourceBytes;
} else if (sourcePath != null) {
try {
original = Files.readAllBytes(sourcePath);
} catch (IOException e) {
throw new PdfiumException(
"Failed to read source PDF for metadata update: " + sourcePath, e);
private Map<MetadataTag, String> buildMergedMetadata() {
if (pendingMetadata.isEmpty()) {
return pendingMetadata;
}

Map<MetadataTag, String> merged = new LinkedHashMap<>();
// Read all existing metadata from the native PDFium document
for (MetadataTag tag : MetadataTag.values()) {
if (!pendingMetadata.containsKey(tag)) {
readNativeMetadata(tag).ifPresent(v -> merged.put(tag, v));
}
} else {
// No original bytes available, fall back to full save
return saveToBytes();
}
return PdfSaver.applyIncrementalUpdate(original, pendingMetadata, pendingXmpMetadata);
// Apply pending changes (overrides existing + adds new entries)
for (Map.Entry<MetadataTag, String> entry : pendingMetadata.entrySet()) {
if (entry.getValue() != null && !entry.getValue().isEmpty()) {
merged.put(entry.getKey(), entry.getValue());
}
// Empty string means "clear this tag" - don't add it to merged
}
return merged;
}

/**
Expand All @@ -1293,12 +1296,7 @@ private byte[] saveMetadataOnly() {
* @throws PdfiumException if saving fails
*/
public void save(OutputStream out) {
byte[] bytes;
if (structurallyModified) {
bytes = saveToBytes();
} else {
bytes = saveMetadataOnly();
}
byte[] bytes = saveToBytes();
try {
out.write(bytes);
} catch (IOException e) {
Expand Down
Loading
Loading