248 lines
13 KiB
Python
248 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""AcroForm, crop, metadata and embedded-image operations using existing pypdf."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from _pdf_common import (SkillArgumentParser, input_pdf, output_pdf, output_directory,
|
||
new_temp_pdf, publish_temp_file, parse_page_spec, run_cli)
|
||
|
||
|
||
def load_data(inline, filename):
|
||
if bool(inline) == bool(filename):
|
||
raise ValueError('必须且只能提供 --data 或 --data-file')
|
||
if filename and Path(filename).stat().st_size > 2 * 1024 * 1024:
|
||
raise ValueError('JSON 上限为 2 MiB')
|
||
raw = Path(filename).read_bytes() if filename else inline.encode('utf-8')
|
||
if len(raw) > 2 * 1024 * 1024:
|
||
raise ValueError('JSON 上限为 2 MiB')
|
||
data = json.loads(raw)
|
||
if not isinstance(data, dict):
|
||
raise ValueError('JSON 必须是对象')
|
||
return data
|
||
|
||
|
||
def inherited_value(field, key, default=None):
|
||
"""Read an inheritable field attribute without following malformed cycles."""
|
||
seen = set()
|
||
while field is not None:
|
||
field = field.get_object()
|
||
if id(field) in seen:
|
||
raise ValueError('PDF 表单字段的父级引用存在循环')
|
||
seen.add(id(field))
|
||
if key in field:
|
||
return field[key]
|
||
field = field.get('/Parent')
|
||
return default
|
||
|
||
|
||
def field_info(reader):
|
||
result = {}
|
||
for name, field in (reader.get_fields() or {}).items():
|
||
# get_fields() is a summary: /AP and /MaxLen live on the full object.
|
||
original = field.indirect_reference.get_object()
|
||
ft = str(inherited_value(original, '/FT', ''))
|
||
flags = int(inherited_value(original, '/Ff', 0))
|
||
kind = {'/Tx':'text', '/Ch':'choice', '/Sig':'signature'}.get(ft, 'unknown')
|
||
if ft == '/Btn':
|
||
kind = 'pushbutton' if flags & (1 << 16) else ('radio' if flags & (1 << 15) else 'checkbox')
|
||
widgets = [original, *[kid.get_object() for kid in original.get('/Kids', [])]]
|
||
states = set()
|
||
if kind in {'checkbox', 'radio'}:
|
||
for widget in widgets:
|
||
appearance = widget.get('/AP')
|
||
normal = appearance.get_object().get('/N') if appearance is not None else None
|
||
if normal is not None:
|
||
states.update(str(key) for key in normal.get_object().keys())
|
||
if kind == 'radio' and flags & (1 << 14):
|
||
states.discard('/Off')
|
||
options = [{'value':str(option[0]), 'label':str(option[1])} if isinstance(option, list) else {'value':str(option), 'label':str(option)}
|
||
for option in inherited_value(original, '/Opt', [])]
|
||
value = inherited_value(original, '/V')
|
||
result[name] = {'id':name, 'type':kind, 'read_only':bool(flags & 1), 'flags':flags,
|
||
'current_value':[str(v) for v in value] if isinstance(value, list) else (str(value) if value is not None else None),
|
||
'states':sorted(states), 'options':options, 'max_length':inherited_value(original, '/MaxLen')}
|
||
return result
|
||
|
||
|
||
def validated_values(infos, data):
|
||
from pypdf.generic import NameObject
|
||
if not data or len(data) > 500:
|
||
raise ValueError('填表数据需为 1–500 个字段')
|
||
values = {}
|
||
for name, value in data.items():
|
||
if name not in infos:
|
||
raise ValueError(f'表单字段不存在:{name}')
|
||
field = infos[name]
|
||
if field['read_only']:
|
||
raise ValueError(f'字段为只读:{name}')
|
||
kind = field['type']
|
||
if kind in {'signature','pushbutton','unknown'}:
|
||
raise ValueError(f'不支持填写 {kind} 字段:{name}')
|
||
if kind in {'checkbox','radio'}:
|
||
states = field['states']
|
||
if type(value) is bool and kind == 'checkbox':
|
||
choices = [s for s in states if s != '/Off']
|
||
if value and len(choices) != 1:
|
||
raise ValueError(f'{name} 的选中状态不唯一,请使用具体状态名')
|
||
value = choices[0] if value else '/Off'
|
||
elif isinstance(value, str):
|
||
value = '/' + value.lstrip('/')
|
||
else:
|
||
raise ValueError(f'{name} 需布尔值或有效状态名')
|
||
if value not in states:
|
||
raise ValueError(f'{name} 状态无效;可选:{states}')
|
||
values[name] = NameObject(value)
|
||
elif kind == 'choice':
|
||
choices = {entry['value'] for entry in field['options']}
|
||
selected = value if isinstance(value, list) else [value]
|
||
if isinstance(value, list) and not field['flags'] & (1 << 21):
|
||
raise ValueError(f'{name} 不支持多选')
|
||
editable = bool(field['flags'] & (1 << 18))
|
||
if not all(isinstance(item, str) and (editable or item in choices) for item in selected):
|
||
raise ValueError(f'{name} 选项无效;可选:{sorted(choices)}')
|
||
values[name] = value
|
||
else:
|
||
if not isinstance(value, str) or (field['max_length'] and len(value) > field['max_length']):
|
||
raise ValueError(f'{name} 需文本,且不得超过字段长度上限')
|
||
values[name] = value
|
||
return values
|
||
|
||
|
||
def execute(args):
|
||
from pypdf import PdfReader, PdfWriter
|
||
from pypdf.generic import RectangleObject
|
||
|
||
source = input_pdf(args.input)
|
||
with source.open('rb') as handle:
|
||
reader = PdfReader(handle)
|
||
if reader.is_encrypted:
|
||
raise ValueError('PDF 已加密,请提供已解密副本')
|
||
if args.operation == 'form-info':
|
||
fields = list(field_info(reader).values())
|
||
if args.offset < 0 or not 1 <= args.limit <= 200:
|
||
raise ValueError('offset 不能小于 0,limit 需为 1–200')
|
||
stop = min(len(fields), args.offset + args.limit)
|
||
acroform = reader.root_object.get('/AcroForm')
|
||
return {'field_count':len(fields), 'fields':fields[args.offset:stop], 'has_more':stop < len(fields),
|
||
'next_offset':stop if stop < len(fields) else None,
|
||
'has_xfa':bool(acroform and '/XFA' in acroform.get_object())}
|
||
if args.operation == 'extract-images':
|
||
return extract_images(reader, args)
|
||
output = output_pdf(args.output, args.overwrite)
|
||
if output == source:
|
||
raise ValueError('输出文件不能覆盖源 PDF')
|
||
writer = PdfWriter(clone_from=reader)
|
||
temporary = new_temp_pdf(output)
|
||
extra = {}
|
||
values = {}
|
||
try:
|
||
if args.operation == 'form-fill':
|
||
acroform = reader.root_object.get('/AcroForm')
|
||
if acroform and '/XFA' in acroform.get_object():
|
||
raise ValueError('XFA 表单不属于 AcroForm 固定接口,不能声称填写成功')
|
||
values = validated_values(field_info(reader), load_data(args.data, args.data_file))
|
||
writer.update_page_form_field_values(None, values, auto_regenerate=False)
|
||
extra = {'fields_filled':list(values), 'requires_visual_review':True}
|
||
elif args.operation == 'metadata':
|
||
data = load_data(args.data, args.data_file)
|
||
allowed = {'Title','Author','Subject','Keywords','Creator','Producer'}
|
||
if set(data) - allowed or not all(isinstance(v,str) and len(v) <= 4096 for v in data.values()):
|
||
raise ValueError('元数据仅支持 Title/Author/Subject/Keywords/Creator/Producer 文本字段')
|
||
writer.add_metadata({'/'+key:value for key,value in data.items()})
|
||
extra = {'updated_keys':list(data), 'xmp_preserved':'/Metadata' in reader.root_object}
|
||
elif args.operation == 'crop':
|
||
box = [float(v) for v in args.box.split(',')]
|
||
if len(box) != 4 or not all(math.isfinite(v) for v in box) or box[0] >= box[2] or box[1] >= box[3]:
|
||
raise ValueError('box 需为左,下,右,上四个有限坐标,单位 pt')
|
||
pages = parse_page_spec(args.pages, len(writer.pages))
|
||
for number in pages:
|
||
media = writer.pages[number-1].mediabox
|
||
if box[0] < media.left or box[1] < media.bottom or box[2] > media.right or box[3] > media.top:
|
||
raise ValueError(f'裁剪框超出第 {number} 页 MediaBox')
|
||
writer.pages[number-1].cropbox = RectangleObject((box[0], box[1], box[2], box[3]))
|
||
extra = {'cropped_pages':pages, 'box':box, 'warning':'裁剪只改变可见范围,不删除隐藏内容,不能用于脱敏。'}
|
||
with temporary.open('wb') as stream:
|
||
writer.write(stream)
|
||
check = PdfReader(temporary)
|
||
if len(check.pages) != len(reader.pages):
|
||
raise ValueError('编辑后页数异常')
|
||
if args.operation == 'form-fill':
|
||
actual = field_info(check)
|
||
for name, value in values.items():
|
||
wanted = [str(v) for v in value] if isinstance(value, list) else str(value)
|
||
if name not in actual or actual[name]['current_value'] != wanted:
|
||
raise ValueError(f'字段回读校验失败:{name}')
|
||
publish_temp_file(temporary, output, args.overwrite)
|
||
finally:
|
||
writer.close()
|
||
temporary.unlink(missing_ok=True)
|
||
return {'path':str(output), 'source':str(source), 'page_count':len(reader.pages), 'operation':args.operation, **extra}
|
||
|
||
|
||
def extract_images(reader, args):
|
||
if not 1 <= args.max_images <= 50 or args.start_image < 0:
|
||
raise ValueError('max-images 需为 1–50,start-image 不能小于 0')
|
||
pages = parse_page_spec(args.pages, len(reader.pages))
|
||
if len(pages) > 4:
|
||
raise ValueError('一次最多处理 4 页,请指定 pages')
|
||
destination = output_directory(args.output_dir)
|
||
result, total_bytes = [], 0
|
||
for page_index, number in enumerate(pages):
|
||
images = reader.pages[number-1].images
|
||
start = args.start_image if page_index == 0 else 0
|
||
for index in range(start, len(images)):
|
||
if len(result) >= args.max_images:
|
||
return {'images':result, 'has_more':True, 'next_page':number, 'next_image':index,
|
||
'remaining_pages':pages[page_index:], 'note':'嵌入图片不是整页截图;页面外观请用 render_pdf.py。'}
|
||
item = images[index]
|
||
if item.image.width * item.image.height > 20_000_000:
|
||
raise ValueError('嵌入图像超过 2000 万像素,请改为限制 DPI 的页面渲染')
|
||
total_bytes += len(item.data)
|
||
if total_bytes > 25 * 1024 * 1024:
|
||
raise ValueError('本批嵌入图片超过 25 MiB,请减少页数或图片数')
|
||
extension = Path(item.name).suffix.lower()
|
||
if extension not in {'.png','.jpg','.jpeg','.jp2','.tif','.tiff'}:
|
||
raise ValueError(f'不支持的嵌入图片编码:{extension},请用页面渲染')
|
||
output = destination / f'page-{number:04d}-image-{index:03d}{extension}'
|
||
temporary = new_temp_pdf(output)
|
||
try:
|
||
temporary.write_bytes(item.data)
|
||
publish_temp_file(temporary, output, args.overwrite)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
result.append({'page':number, 'image_index':index, 'path':str(output)})
|
||
return {'images':result, 'has_more':False, 'note':'嵌入图片不是整页截图;页面外观请用 render_pdf.py。'}
|
||
|
||
|
||
def main(argv=None):
|
||
parser = SkillArgumentParser(description='PDF 表单、裁剪、元数据和嵌入图片固定接口')
|
||
commands = parser.add_subparsers(dest='operation', required=True)
|
||
for name in ('form-info','form-fill','metadata','crop','extract-images'):
|
||
command = commands.add_parser(name)
|
||
command.add_argument('--input', required=True)
|
||
if name == 'form-info':
|
||
command.add_argument('--offset', type=int, default=0)
|
||
command.add_argument('--limit', type=int, default=50)
|
||
else:
|
||
command.add_argument('--overwrite', action='store_true')
|
||
command.add_argument('--output-dir' if name == 'extract-images' else '--output', required=True)
|
||
if name in {'form-fill','metadata'}:
|
||
command.add_argument('--data')
|
||
command.add_argument('--data-file')
|
||
if name in {'crop','extract-images'}:
|
||
command.add_argument('--pages')
|
||
if name == 'crop':
|
||
command.add_argument('--box', required=True)
|
||
if name == 'extract-images':
|
||
command.add_argument('--start-image', type=int, default=0)
|
||
command.add_argument('--max-images', type=int, default=20)
|
||
return run_cli(lambda: execute(parser.parse_args(sys.argv[1:] if argv is None else argv)))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|