介紹
當法律團隊或鑑識分析師需要證明文件未被篡改時,僅僅查看可見內容是不夠的。隱藏的屬性——例如作者、建立日期或修訂號——可以揭示誰在何時觸碰過檔案。跨文件版本檢測這些細微變化是一個常見的痛點,往往需要手動檢查每個屬性,既耗時又容易出錯。
GroupDocs.Metadata for Java 提供了一種程式化方式來提取所有元資料欄位,並在兩個版本之間計算結構化差異。在本教學中,我們將比較三種實用方法:完整的元資料差異、聚焦於所有權變更的檢測,以及修訂歷史分析。每種方法都以簡潔、可直接複製貼上的程式碼示範,並說明如何將結果匯出為 CSV 或 JSON 以供稽核報告使用。
我在審查一份經過多方在數月內編輯的合約時遇到了這個問題;可見文字看起來相同,但所有權欄位卻悄悄變更了。
如何判斷兩個文件版本之間哪些元資料欄位發生了變化?
GroupDocs.Metadata 會載入每個檔案,將所有可取得的屬性提取到映射表中,然後遍歷鍵以分類新增、刪除和修改。該函式庫同時處理內建與自訂標籤,讓您無需撰寫特定格式的解析器即可獲得完整圖景。結果是一個 MetadataDiff 物件,您可以查詢或序列化它以生成合規報告。
前置條件
- Java 8 或更新版本
- GroupDocs.Metadata for Java 24.7(temporary license)
- 兩個您想比較的文件(例如
contract_v1.pdf與contract_v2.pdf)
安裝
透過 Maven 加入相依性:
<dependency>
<groupId>com.groupdocs</groupId>
<artifactId>groupdocs-metadata</artifactId>
<version>24.7</version>
</dependency>
方法 1 – 完整元資料差異
此方法會從兩個版本中提取 所有 元資料屬性,並報告新增、刪除與變更的條目。
// CompareMetadataSets.run – returns a MetadataDiff object
Map<String, String> v1 = ExtractAllMetadata.run(pathV1);
Map<String, String> v2 = ExtractAllMetadata.run(pathV2);
MetadataDiff diff = new MetadataDiff();
// Detect added and changed properties
for (Map.Entry<String, String> e : v2.entrySet()) {
String key = e.getKey();
String val = e.getValue();
if (!v1.containsKey(key)) {
diff.added.put(key, val); // New property in v2
} else if (!v1.get(key).equals(val)) {
diff.changed.put(key, new String[]{v1.get(key), val}); // Value changed
}
}
// Detect removed properties
for (Map.Entry<String, String> e : v1.entrySet()) {
if (!v2.containsKey(e.getKey())) {
diff.removed.put(e.getKey(), e.getValue());
}
}
return diff;
重點:
- 全面:捕獲所有標籤,包括自訂標籤。
- 簡單的映射邏輯:不需要外部差異函式庫。
- 結果物件:
added、removed、changed三個映射可直接進行後續處理。
💡 小技巧:當您需要完整的稽核追蹤以符合監管要求時,使用此方法。
方法 2 – 檢測所有權變更
法律爭議常常取決於誰建立或編輯了文件。此方法聚焦於與人員相關的標籤,如 Creator、Editor、Manager 與 Company。
// DetectOwnershipChanges.run – returns a map of changed ownership fields
Map<String, String> v1 = readOwnership(pathV1);
Map<String, String> v2 = readOwnership(pathV2);
Set<String> allKeys = new HashSet<>(v1.keySet());
allKeys.addAll(v2.keySet());
Map<String, String[]> changes = new LinkedHashMap<>();
for (String key : allKeys) {
String oldVal = v1.getOrDefault(key, "<missing>");
String newVal = v2.getOrDefault(key, "<missing>");
if (!oldVal.equals(newVal)) {
changes.put(key, new String[]{oldVal, newVal});
}
}
return changes;
readOwnership 只提取相關標籤:
Map<String, String> result = new LinkedHashMap<>();
try (Metadata metadata = new Metadata(path)) {
if (metadata.getFileFormat() == FileFormat.Unknown) return result;
for (MetadataProperty p : metadata.findProperties(
new ContainsTagSpecification(Tags.getPerson().getCreator())
.or(new ContainsTagSpecification(Tags.getPerson().getEditor()))
.or(new ContainsTagSpecification(Tags.getPerson().getManager()))
.or(new ContainsTagSpecification(Tags.getCorporate().getCompany())))) {
String value = "";
if (p.getValue() != null && p.getValue().getRawValue() != null) {
value = String.valueOf(p.getValue().getRawValue());
}
result.put(p.getName(), value);
}
}
return result;
重點:
- 針對性:僅檢查身份相關的屬性。
- 清晰輸出:回傳的映射每筆條目顯示
[old, new]值。 - 合規就緒:非常適合 e‑discovery 或合約所有權爭議。
💡 小技巧:若同時需要廣度與深度,可將此方法與完整差異結合使用。
方法 3 – 檢測修訂歷史變更
修訂號、編輯時間戳與列印日期對最終使用者而言是不可見的,但對鑑識時間線至關重要。此方法僅抽取與時間相關的標籤。
Map<String, String> v1 = readRevision(pathV1);
Map<String, String> v2 = readRevision(pathV2);
Set<String> allKeys = new HashSet<>(v1.keySet());
allKeys.addAll(v2.keySet());
Map<String, String[]> changes = new LinkedHashMap<>();
for (String key : allKeys) {
String oldVal = v1.getOrDefault(key, "<missing>");
String newVal = v2.getOrDefault(key, "<missing>");
if (!oldVal.equals(newVal)) {
changes.put(key, new String[]{oldVal, newVal});
}
}
return changes;
readRevision 抽取 Modified、Created 與 Printed 時間戳:
Map<String, String> result = new LinkedHashMap<>();
try (Metadata metadata = new Metadata(path)) {
if (metadata.getFileFormat() == FileFormat.Unknown) return result;
for (MetadataProperty p : metadata.findProperties(
new ContainsTagSpecification(Tags.getTime().getModified())
.or(new ContainsTagSpecification(Tags.getTime().getCreated()))
.or(new ContainsTagSpecification(Tags.getTime().getPrinted())))) {
String value = "";
if (p.getValue() != null && p.getValue().getRawValue() != null) {
value = String.valueOf(p.getValue().getRawValue());
}
result.put(p.getName(), value);
}
}
return result;
重點:
- 時間線重建:顯示檔案被編輯或列印的次數。
- 數值差異:有助於偵測可疑的快速修訂。
- 輕量:僅查詢三個標籤,執行速度快。
💡 小技巧:當您需要證明文件在特定截止日期後未被更改時,使用此方法。
方法比較:何時使用哪種
| 方法 | 最適用情境 | 主要優勢 | 限制 |
|---|---|---|---|
| Full Metadata Diff | 完整稽核、監管合規 | 捕獲所有屬性,包括自訂標籤 | 大檔案時記憶體佔用較高 |
| Ownership Change Detection | 法律所有權爭議、e‑discovery | 聚焦於人員相關欄位,易於閱讀 | 忽略其他有用的元資料 |
| Revision History Detection | 時間線鑑識、變更紀錄驗證 | 單獨抽取時間戳與修訂號 | 不顯示內容層面的變更 |
依照您的合規目標選擇合適的方法。多數情況下,結合使用——先執行完整差異,再針對所有權或修訂部分深入探查——能提供最完整的洞見。
匯出差異
取得 MetadataDiff 後,通常需要分享結果。以下提供兩個簡易匯出範例。
CSV 匯出
StringBuilder sb = new StringBuilder();
sb.append("change_type,property,old_value,new_value\n");
for (Map.Entry<String, String> e : diff.added.entrySet()) {
sb.append("added,").append(esc(e.getKey()))
.append(",,").append(esc(e.getValue())).append("\n");
}
for (Map.Entry<String, String> e : diff.removed.entrySet()) {
sb.append("removed,").append(esc(e.getKey()))
.append(",").append(esc(e.getValue()))
.append(",\n");
}
for (Map.Entry<String, String[]> e : diff.changed.entrySet()) {
sb.append("changed,").append(esc(e.getKey()))
.append(",").append(esc(e.getValue()[0]))
.append(",").append(esc(e.getValue()[1])).append("\n");
}
Files.write(Paths.get(outputPath), sb.toString().getBytes(StandardCharsets.UTF_8));
JSON 匯出
StringBuilder sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"added\": {\n");
writeMap(sb, diff.added);
sb.append(" },\n");
sb.append(" \"removed\": {\n");
writeMap(sb, diff.removed);
sb.append(" },\n");
sb.append(" \"changed\": {\n");
int i = 0;
for (Map.Entry<String, String[]> e : diff.changed.entrySet()) {
String comma = ++i < diff.changed.size() ? "," : "";
sb.append(" \"").append(escape(e.getKey()))
.append("\": { \"from\": \"")
.append(escape(e.getValue()[0])).append("\", \"to\": \"")
.append(escape(e.getValue()[1])).append("\" }")
.append(comma).append("\n");
}
sb.append(" }\n");
sb.append("}\n");
Files.write(Paths.get(outputPath), sb.toString().getBytes(StandardCharsets.UTF_8));
兩個匯出器皆依賴於 esc、escape、writeMap 等輔助方法,以安全處理逗號與引號。
最佳實踐與技巧
- 限定差異範圍:對於大型 PDF,僅限制在所有權或修訂標籤上,可減少處理時間。
- 驗證檔案格式:在遍歷屬性前,務必檢查
metadata.getFileFormat() != FileFormat.Unknown。 - 釋放資源:使用 try‑with‑resources (
try (Metadata metadata = new Metadata(path)) { … }) 釋放本機句柄。 - 版本一致性:確保兩個文件屬於相同的檔案格式版本;混用 DOCX 與舊版 DOC 可能產生誤導結果。
- 安全性:未經清理不要在公開 API 中直接暴露原始元資料值;寫入 CSV/JSON 時使用
esc/escape幫助函式。 - 效能:大量匯入時建議使用 CSV 供 SIEM 處理;JSON 更適合人類可讀的稽核日誌。
結論
GroupDocs.Metadata for Java 讓元資料鑑識變得簡單。透過完整差異、所有權檢測與修訂歷史分析三種方法,您可以構建一套強大的稽核管線,揭露隱藏變更、支援法律證據,並滿足合規需求。匯出為 CSV 或 JSON 更能無縫整合報告工具或資料倉儲管線。
後續步驟:
- 探索進階標籤規格以過濾自訂元資料 GroupDocs.Metadata Java docs。
- 了解如何批次比較多個文件 API reference。
- 查看官方範例專案以獲得端到端實作 GitHub examples。