Files
ofdgo/ofdgo_pdf_metadata.go
T
2026-10-18 18:08:52 +08:00

353 lines
11 KiB
Go

// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"context"
"encoding/base64"
"encoding/xml"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/xiaoqidun/pdfgo"
)
// PDF元数据使用的RDF、Dublin Core及Adobe命名空间
const (
pdfMetadataRDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
pdfMetadataDC = "http://purl.org/dc/elements/1.1/"
pdfMetadataXMP = "http://ns.adobe.com/xap/1.0/"
pdfMetadataPDF = "http://ns.adobe.com/pdf/1.3/"
pdfMetadataXML = "adobe:ns:meta/"
)
// PDF来源元数据的标引定义
const (
pdfMetadataTagType = "OFDGo.PDFMetadata"
pdfInformationTagType = "OFDGo.PDFInformation"
pdfInformationNamespace = "https://ofdgo.aite.me/ns/pdf/information/1"
)
// pdfMetadataValue 保留字段是否声明及其来源,空文本不作为缺省值
type pdfMetadataValue struct {
text string
xmp bool
}
// documentMetadata 保留PDF元数据原文并映射文档描述,来源软件与扩展字段保存在独立标引中
// 入参: ctx 取消上下文
// 返回: error 读取、解析、日期或提交错误
func (p *pdfImporter) documentMetadata(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
info, err := p.reader.Info()
if err != nil {
return err
}
values, original, err := p.informationMetadata(ctx, info)
if err != nil {
return err
}
stream, err := p.reader.Metadata()
if err != nil {
return err
}
var packet []byte
var root *StructureNode
if stream != nil {
packet, err = stream.DecodeContext(ctx)
if err != nil {
return err
}
root, err = parseStructure(ctx, packet, CustomTag{})
if err != nil {
return err
}
xmp, err := pdfMetadataFields(ctx, root)
if err != nil {
return err
}
newer := false
if stamp, ok := xmp["MetadataDate"]; ok && stamp.text != "" && info.ModDate != "" {
date, _, zoned, err := pdfMetadataDate(stamp)
if err != nil {
return err
}
modified, err := pdfgo.ParseDate(info.ModDate)
if err != nil {
return err
}
newer = zoned && modified.After(date)
}
for key, value := range xmp {
if _, exists := values[key]; !newer || !exists {
values[key] = value
}
}
}
projected := p.editor.Info
for _, field := range []struct {
name string
text *string
}{{"Title", &projected.Title}, {"Author", &projected.Author}, {"Subject", &projected.Subject}} {
if value, ok := values[field.name]; ok && structureXMLText(value.text) {
*field.text = value.text
}
}
if value, ok := values["Keywords"]; ok && structureXMLText(value.text) {
projected.Keywords = Keywords{value.text}
}
for _, field := range []struct {
name string
text *string
}{{"CreationDate", &projected.CreationDate}, {"ModDate", &projected.ModDate}} {
if value, ok := values[field.name]; ok {
date, day, _, err := pdfMetadataDate(value)
if err != nil {
return fmt.Errorf("PDF metadata %s: %w", field.name, err)
}
*field.text = ""
if day {
*field.text = date.Format("2006-01-02")
}
}
}
if err := ctx.Err(); err != nil {
return err
}
p.editor.SetInfo(projected)
if root != nil {
if _, err := p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfMetadataTagType, NameSpace: root.Namespace}, packet); err != nil {
return err
}
}
if len(original.Children) != 0 {
data, err := encodeStructureNode(original)
if err != nil {
return err
}
_, err = p.editor.addStructureXML(ctx, CustomTag{TypeID: pdfInformationTagType, NameSpace: pdfInformationNamespace}, data)
return err
}
return ctx.Err()
}
// informationMetadata 收集已声明字段并保存原始文本,XML无法表示的字符使用Base64保留
// 入参: ctx 取消上下文, info 已解码的信息字典
// 返回: map[string]pdfMetadataValue 可映射字段, StructureNode 原始字段, error 读取或取消错误
func (p *pdfImporter) informationMetadata(ctx context.Context, info pdfgo.DocumentInfo) (map[string]pdfMetadataValue, StructureNode, error) {
root := StructureNode{Name: "Information", Namespace: pdfInformationNamespace}
values := make(map[string]pdfMetadataValue)
object, err := p.reader.Resolve(p.reader.Trailer["Info"])
if err != nil || object == nil {
return values, root, err
}
dict, ok := object.(pdfgo.Dictionary)
if !ok {
return nil, root, fmt.Errorf("invalid PDF information dictionary")
}
known := map[pdfgo.Name]string{
"Title": info.Title, "Author": info.Author, "Subject": info.Subject, "Keywords": info.Keywords,
"Creator": info.Creator, "Producer": info.Producer, "CreationDate": info.CreationDate, "ModDate": info.ModDate,
"Trapped": string(info.Trapped),
}
for _, key := range slices.Sorted(maps.Keys(dict)) {
if err := ctx.Err(); err != nil {
return nil, root, err
}
value, err := p.reader.Resolve(dict[key])
if err != nil {
return nil, root, err
}
if value == nil {
continue
}
text, standard := known[key]
if !standard {
text = info.Custom[key]
} else {
values[string(key)] = pdfMetadataValue{text: text}
}
name := string(key)
field := StructureNode{Name: "Field", Namespace: pdfInformationNamespace}
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Name", Value: name})
if !structureXMLText(name) {
field.Attributes[0].Value = base64.StdEncoding.EncodeToString([]byte(name))
field.Attributes = append(field.Attributes, StructureAttribute{Name: "NameEncoding", Value: "base64"})
}
if key == "Trapped" {
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Type", Value: "name"})
}
if !structureXMLText(text) {
text = base64.StdEncoding.EncodeToString([]byte(text))
field.Attributes = append(field.Attributes, StructureAttribute{Name: "Encoding", Value: "base64"})
}
field.Text = text
root.Children = append(root.Children, field)
}
return values, root, nil
}
// pdfMetadataFields 读取文档级RDF描述,日期及PDF关键词不与其他同名扩展混用
// 入参: ctx 取消上下文, root 元数据根节点
// 返回: map[string]pdfMetadataValue 已声明的对应字段, error 取消错误
func pdfMetadataFields(ctx context.Context, root *StructureNode) (map[string]pdfMetadataValue, error) {
result := make(map[string]pdfMetadataValue)
var rdf *StructureNode
if root.Namespace == pdfMetadataRDF && root.Name == "RDF" {
rdf = root
} else if root.Namespace == pdfMetadataXML && (root.Name == "xmpmeta" || root.Name == "xapmeta") {
for i := range root.Children {
if err := ctx.Err(); err != nil {
return nil, err
}
child := &root.Children[i]
if child.Namespace == pdfMetadataRDF && child.Name == "RDF" {
rdf = child
break
}
}
}
if rdf == nil {
return result, ctx.Err()
}
fields := map[xml.Name]string{
{Space: pdfMetadataDC, Local: "title"}: "Title", {Space: pdfMetadataDC, Local: "creator"}: "Author",
{Space: pdfMetadataDC, Local: "description"}: "Subject", {Space: pdfMetadataPDF, Local: "Keywords"}: "Keywords",
{Space: pdfMetadataXMP, Local: "CreateDate"}: "CreationDate", {Space: pdfMetadataXMP, Local: "ModifyDate"}: "ModDate",
{Space: pdfMetadataXMP, Local: "MetadataDate"}: "MetadataDate",
}
for _, description := range rdf.Children {
if err := ctx.Err(); err != nil {
return nil, err
}
if description.Namespace != pdfMetadataRDF || description.Name != "Description" {
continue
}
for _, attr := range description.Attributes {
if key, ok := fields[xml.Name{Space: attr.Namespace, Local: attr.Name}]; ok && attr.Namespace != pdfMetadataDC {
result[key] = pdfMetadataValue{text: attr.Value, xmp: true}
}
}
for _, property := range description.Children {
if err := ctx.Err(); err != nil {
return nil, err
}
key, ok := fields[xml.Name{Space: property.Namespace, Local: property.Name}]
if !ok {
continue
}
arrayValue := property.Namespace == pdfMetadataDC
property = pdfMetadataProperty(property)
if arrayValue {
kind := "Alt"
if key == "Author" {
kind = "Seq"
}
found := false
for _, array := range property.Children {
if array.Namespace == pdfMetadataRDF && array.Name == kind && len(array.Children) != 0 {
item := array.Children[0]
if item.Namespace == pdfMetadataRDF && item.Name == "li" {
property = item
found = true
}
break
}
}
if !found {
continue
}
}
if text, ok := pdfMetadataText(property); ok {
result[key] = pdfMetadataValue{text: text, xmp: true}
}
}
}
return result, ctx.Err()
}
// pdfMetadataProperty 提取带限定符的rdf:value,保留数组及结构边界
// 入参: node 属性节点
// 返回: StructureNode 属性值节点
func pdfMetadataProperty(node StructureNode) StructureNode {
for {
if len(node.Children) == 1 && node.Children[0].Namespace == pdfMetadataRDF && node.Children[0].Name == "Description" {
node = node.Children[0]
continue
}
for _, attr := range node.Attributes {
if attr.Namespace == pdfMetadataRDF && attr.Name == "value" {
return StructureNode{Text: attr.Value}
}
}
found := false
for _, child := range node.Children {
if child.Namespace == pdfMetadataRDF && child.Name == "value" {
node, found = child, true
break
}
}
if !found {
return node
}
}
}
// pdfMetadataText 读取简单值,不拼接结构或数组文本,也不将资源URI当作文本
// 入参: node 属性节点
// 返回: string 文本, bool 是否为支持的简单值
func pdfMetadataText(node StructureNode) (string, bool) {
node = pdfMetadataProperty(node)
for _, attr := range node.Attributes {
if attr.Namespace == pdfMetadataRDF && (attr.Name == "resource" || attr.Name == "nodeID" || attr.Name == "parseType") {
return "", false
}
}
return node.Text, len(node.Children) == 0
}
// pdfMetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算
// 入参: value 来源日期
// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误
func pdfMetadataDate(value pdfMetadataValue) (time.Time, bool, bool, error) {
if value.text == "" {
return time.Time{}, false, false, nil
}
if !value.xmp {
date, err := pdfgo.ParseDate(value.text)
return date, err == nil && date.Year() > 0, err == nil, err
}
if !strings.Contains(value.text, ",") {
for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04Z07:00", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02", "2006-01", "2006"} {
if date, err := time.Parse(layout, value.text); err == nil {
zoned := strings.Contains(layout, "Z07:00")
if zoned && !strings.HasSuffix(value.text, "Z") {
zone := value.text[len(value.text)-6:]
if zone[1:3] > "23" || zone[4:] > "59" {
return time.Time{}, false, false, fmt.Errorf("invalid XMP time zone")
}
}
return date, len(value.text) >= 10 && date.Year() > 0, zoned, nil
}
}
}
return time.Time{}, false, false, fmt.Errorf("invalid XMP date")
}