介紹

當法律團隊或鑑識分析師需要證明文件未被篡改時,僅僅查看可見內容是不夠的。隱藏的屬性——例如作者、建立日期或修訂號——可以揭示誰在何時觸碰過檔案。跨文件版本檢測這些細微變化是一個常見的痛點,往往需要手動檢查每個屬性,既耗時又容易出錯。

GroupDocs.Metadata for Java 提供了一種程式化方式來提取所有元資料欄位,並在兩個版本之間計算結構化差異。在本教學中,我們將比較三種實用方法:完整的元資料差異、聚焦於所有權變更的檢測,以及修訂歷史分析。每種方法都以簡潔、可直接複製貼上的程式碼示範,並說明如何將結果匯出為 CSV 或 JSON 以供稽核報告使用。

我在審查一份經過多方在數月內編輯的合約時遇到了這個問題;可見文字看起來相同,但所有權欄位卻悄悄變更了。

如何判斷兩個文件版本之間哪些元資料欄位發生了變化?

GroupDocs.Metadata 會載入每個檔案,將所有可取得的屬性提取到映射表中,然後遍歷鍵以分類新增、刪除和修改。該函式庫同時處理內建與自訂標籤,讓您無需撰寫特定格式的解析器即可獲得完整圖景。結果是一個 MetadataDiff 物件,您可以查詢或序列化它以生成合規報告。

前置條件

  • Java 8 或更新版本
  • GroupDocs.Metadata for Java 24.7(temporary license
  • 兩個您想比較的文件(例如 contract_v1.pdfcontract_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;

重點:

  • 全面:捕獲所有標籤,包括自訂標籤。
  • 簡單的映射邏輯:不需要外部差異函式庫。
  • 結果物件addedremovedchanged 三個映射可直接進行後續處理。

💡 小技巧:當您需要完整的稽核追蹤以符合監管要求時,使用此方法。

方法 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));

兩個匯出器皆依賴於 escescapewriteMap 等輔助方法,以安全處理逗號與引號。

最佳實踐與技巧

  • 限定差異範圍:對於大型 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 更能無縫整合報告工具或資料倉儲管線。

後續步驟:

其他資源