Files
NotesAgentic/backend/app/export/assets.py
T

144 lines
7.6 KiB
Python

"""Raster-only resources; PDF bypasses export quotas but retains path/format validation."""
import base64
import hashlib
import threading
from io import BytesIO
from PIL import Image
from app.errors import ApiError
_math_lock = threading.Lock()
def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False):
"""Embed Vault images and MathText, with format-specific quotas and palette."""
from app.config import get_settings
from urllib.parse import unquote, urlsplit
vault = get_settings().vault_path.resolve()
base = (vault / (file_path or '')).parent if file_path else vault
from app.export.themes import pdf_palette
palette = pdf_palette(options, []) if unlimited and options else None
warnings = []
count = total = pixels = 0
def visit(node):
nonlocal count, total, pixels
if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
count += 1
try:
if not unlimited and count > 64: raise ValueError('resource count')
if node.attributes.get('static_png'):
raw = node.attributes['static_png']
elif node.type == 'image':
src = str(node.attributes.get('src',''))
if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
path = (base / unquote(src)).resolve()
if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or (not unlimited and path.stat().st_size > 2_000_000):
raise ValueError('image path or budget')
raw = path.read_bytes()
else:
source = node.text
depth = 0
for char in source:
depth += (char == '{') - (char == '}')
if not unlimited and depth > 20: raise ValueError('math depth')
if (not unlimited and len(source) > 512) or depth != 0: raise ValueError('math budget')
from matplotlib.mathtext import math_to_image
from matplotlib import rc_context
with _math_lock, rc_context({'savefig.transparent': bool(palette)}):
out = BytesIO()
math_to_image('$'+source+'$', out, dpi=180, format='png', color=palette['text'] if palette else 'black')
raw = out.getvalue()
with Image.open(BytesIO(raw)) as image:
pixels += image.width * image.height
if not unlimited and pixels > 16_000_000: raise ValueError('document pixels')
if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions')
out = BytesIO()
# Composite transparency over the PDF theme or the print/Word white surface.
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white')
background.alpha_composite(rgba); (rgba if preserve_alpha else background.convert('RGB')).save(out,'PNG')
png=out.getvalue();total += len(png)
if not unlimited and total > 8_000_000: raise ValueError('resource bytes')
node.attributes['static_png']=png
except Exception:
node.attributes.pop('static_png', None)
warnings.append('图片无法内嵌(仅支持 Vault 内 PNG/JPEG/WebP),已保留替代文字' if node.type=='image'
else '公式超出 MathText 语法或资源预算,已保留源码' if node.type.startswith('math')
else '静态图表超过文档资源预算,已保留源码')
for child in node.children: visit(child)
for child in document.children: visit(child)
return warnings
def source_hash(source):
return hashlib.sha256(source.strip().encode()).hexdigest()
def validate_assets(assets, unlimited=False):
result = {}
total = pixels = 0
for asset in assets:
try:
raw = base64.b64decode(asset.png_base64, validate=True)
total += len(raw)
if not unlimited and total > 8 * 1024 * 1024:
raise ValueError('asset budget')
with Image.open(BytesIO(raw)) as image:
pixels += image.width * image.height
if not unlimited and pixels > 16_000_000: raise ValueError('document pixel budget')
if image.format != 'PNG' or (not unlimited and image.width * image.height > 4_000_000):
raise ValueError('image budget')
image.load()
out = BytesIO()
rgba = image.convert('RGBA')
background = Image.new('RGBA', rgba.size, 'white')
background.alpha_composite(rgba)
(rgba if unlimited else background.convert('RGB')).save(out, 'PNG')
key = (asset.kind, asset.source_hash)
if key in result:
raise ValueError('duplicate asset')
result[key] = out.getvalue()
except Exception as exc:
raise ApiError(422, 'EXPORT_ASSET_INVALID', 'Invalid PNG or resource budget exceeded.') from exc
return result
def attach_assets(document, assets):
def visit(node):
source = node.attributes.get('src', '') if node.type == 'image' else node.text
key = (node.type, source_hash(source))
if key in assets:
node.attributes['static_png'] = assets[key]
for child in node.children:
visit(child)
for child in document.children:
visit(child)
def plot_png(plot):
"""DOCX consumes the same clipped geometry as SVG/PDF, rendered at 2x."""
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
from PIL import ImageDraw, ImageFont
geo = compute_geometry(plot)
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
draw = ImageDraw.Draw(image)
from app.export.fonts import FONT_PATH
font = ImageFont.truetype(str(FONT_PATH), 20) if FONT_PATH else ImageFont.load_default(size=20)
def line(points, color, width=2):
draw.line([(x * 2, y * 2) for x, y in points], fill=color, width=width)
sx = lambda x: _sx(x, geo.xmin, geo.xmax)
sy = lambda y: _sy(y, geo.ymin, geo.ymax)
for x in geo.xticks:
if geo.grid: line([(sx(x),52),(sx(x),428)], '#d0d7de')
draw.text((sx(x)*2, sy(geo.x_axis_y)*2+8), _fmt_num(x), fill='#57606a', font=font)
for y in geo.yticks:
if geo.grid: line([(52,sy(y)),(588,sy(y))], '#d0d7de')
draw.text((max(0,sx(geo.y_axis_x)*2-75),sy(y)*2), _fmt_num(y), fill='#57606a', font=font)
line([(52,sy(geo.x_axis_y)),(588,sy(geo.x_axis_y))], '#57606a')
line([(sx(geo.y_axis_x),52),(sx(geo.y_axis_x),428)], '#57606a')
for segments, color in zip(geo.polylines,geo.colors):
for segment in segments:
if len(segment)>1: line(segment,color,3)
if geo.xlabel:
draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm')
if geo.ylabel:
# Horizontal at the upper-left margin keeps CJK labels readable in Word.
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
for index, expression in enumerate(plot.expressions):
draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
out=BytesIO(); image.save(out,'PNG')
return out.getvalue(), geo.warnings