Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
"""DrawingML <p:pic> -> SVG <image> conversion.
|
||||
|
||||
Reverse of svg_to_pptx convert_image.
|
||||
|
||||
DrawingML structure:
|
||||
<p:pic>
|
||||
<p:blipFill>
|
||||
<a:blip r:embed="rIdRaster">
|
||||
<a:extLst>...<asvg:svgBlip r:embed="rIdSvg"/>...</a:extLst>
|
||||
</a:blip>
|
||||
<a:srcRect l/t/r/b="1/100000"/> (optional crop)
|
||||
<a:stretch><a:fillRect/></a:stretch> (default: fill the shape)
|
||||
</p:blipFill>
|
||||
<p:spPr>
|
||||
<a:xfrm/>
|
||||
<a:prstGeom prst="rect"/> (usually rect; can be other)
|
||||
</p:spPr>
|
||||
</p:pic>
|
||||
|
||||
Strategy:
|
||||
- Prefer the editable SVG relationship in asvg:svgBlip when Office also stores
|
||||
a raster compatibility preview on a:blip; retain the raster as fallback.
|
||||
- Default (no srcRect, plain stretch) -> a single <image> filling the box,
|
||||
preserveAspectRatio="none".
|
||||
- With srcRect, or with a single oversized tile that covers the frame -> wrap
|
||||
the <image> in a nested <svg viewBox> in the unit rectangle [0,1] x [0,1],
|
||||
with overflow hidden so cropping is expressed identically in browsers and
|
||||
PowerPoint.
|
||||
- Repeating tile fills still use the legacy plain-image fallback; a repeated
|
||||
pattern cannot be represented by the project's native picture-crop subset.
|
||||
- Image bytes are written through the result; the slide assembler decides
|
||||
the href format (external file vs base64).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import mimetypes
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance
|
||||
except ImportError: # pragma: no cover - optional visual enhancement dependency
|
||||
Image = None
|
||||
ImageEnhance = None
|
||||
|
||||
from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha
|
||||
from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PictureDiagnostic:
|
||||
"""Recoverable loss while converting one DrawingML picture."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
fallback: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PictureResult:
|
||||
"""Resolved picture: SVG element string + extracted media bytes."""
|
||||
|
||||
svg: str = ""
|
||||
# Map of {filename: bytes} that the assembler should emit alongside
|
||||
# the SVG. Filename is the basename inside the package's media dir.
|
||||
media: dict[str, bytes] = field(default_factory=dict)
|
||||
diagnostics: tuple[PictureDiagnostic, ...] = ()
|
||||
|
||||
|
||||
class MediaResolutionError(RuntimeError):
|
||||
"""Raised when a PPTX media relationship cannot be reproduced as SVG."""
|
||||
|
||||
|
||||
def convert_blip_fill(
|
||||
blip_fill_elem: ET.Element,
|
||||
xfrm: Xfrm,
|
||||
slide_part: PartRef,
|
||||
pkg: OoxmlPackage,
|
||||
*,
|
||||
media_subdir: str = "assets",
|
||||
embed_inline: bool = False,
|
||||
asset_name_map: dict[str, str] | None = None,
|
||||
strict: bool = False,
|
||||
) -> PictureResult:
|
||||
"""Convert an <a:blipFill> element to SVG <image>.
|
||||
|
||||
Handles image fill for both:
|
||||
- <p:pic><p:blipFill> (standard picture elements)
|
||||
- <p:sp><p:spPr><a:blipFill> (shape with image fill, e.g. Canva exports)
|
||||
"""
|
||||
blip = blip_fill_elem.find("a:blip", NS)
|
||||
if blip is None:
|
||||
return PictureResult()
|
||||
|
||||
relationship_ids = blip_embed_relationship_ids(blip)
|
||||
linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
|
||||
if not relationship_ids:
|
||||
if linked_rid:
|
||||
raise MediaResolutionError(
|
||||
"Linked image relationships are not supported; embed the image in PowerPoint first"
|
||||
)
|
||||
return PictureResult()
|
||||
|
||||
target: str | None = None
|
||||
img_bytes: bytes | None = None
|
||||
failures: list[str] = []
|
||||
for rel_id in relationship_ids:
|
||||
candidate = slide_part.resolve_rel(rel_id)
|
||||
if not candidate:
|
||||
failures.append(f"{rel_id}: unresolved")
|
||||
continue
|
||||
candidate_bytes = pkg.read_media(candidate)
|
||||
if candidate_bytes is None:
|
||||
failures.append(f"{rel_id}: missing {candidate}")
|
||||
continue
|
||||
if Path(candidate).suffix.lower() == ".svg" and not _is_valid_svg_media(candidate_bytes):
|
||||
failures.append(f"{rel_id}: invalid SVG media {candidate}")
|
||||
continue
|
||||
target = candidate
|
||||
img_bytes = candidate_bytes
|
||||
break
|
||||
if target is None or img_bytes is None:
|
||||
details = "; ".join(failures)
|
||||
raise MediaResolutionError(
|
||||
f"No embedded image relationship can be read in {slide_part.path}: {details}"
|
||||
)
|
||||
|
||||
filename = (asset_name_map or {}).get(target, pkg.media_filename(target))
|
||||
filename, img_bytes = _normalize_office_media(filename, img_bytes)
|
||||
tile_source_bytes = img_bytes
|
||||
diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
|
||||
filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
|
||||
filename,
|
||||
img_bytes,
|
||||
blip,
|
||||
)
|
||||
diagnostics.extend(effect_diagnostics)
|
||||
opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip)
|
||||
diagnostics.extend(opacity_diagnostics)
|
||||
if strict and diagnostics:
|
||||
details = "; ".join(item.message for item in diagnostics)
|
||||
raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}")
|
||||
href = _build_href(filename, img_bytes, media_subdir, embed_inline)
|
||||
|
||||
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
|
||||
src_rect = blip_fill_elem.find("a:srcRect", NS)
|
||||
crop = _parse_src_rect(src_rect)
|
||||
|
||||
# A tile larger than its frame is visually a crop, not a stretch. Preserve
|
||||
# that common PowerPoint background construction through the same nested
|
||||
# SVG transport used for srcRect. True repeated patterns retain the legacy
|
||||
# plain-image fallback because native export has no registered tile subset.
|
||||
tile = blip_fill_elem.find("a:tile", NS)
|
||||
if crop is None and tile is not None:
|
||||
crop = _single_cover_tile_crop(
|
||||
tile,
|
||||
blip_fill_elem,
|
||||
xfrm,
|
||||
tile_source_bytes,
|
||||
)
|
||||
|
||||
if crop is None:
|
||||
# Plain unclipped image
|
||||
svg = (
|
||||
f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
|
||||
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
|
||||
f'preserveAspectRatio="none"{opacity_attr}/>'
|
||||
)
|
||||
else:
|
||||
# Crop expressed as a unit-rectangle viewBox on a nested <svg>.
|
||||
vb_l, vb_t, vb_w, vb_h = crop
|
||||
svg = (
|
||||
f'<svg x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
|
||||
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
|
||||
f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} '
|
||||
f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
|
||||
f'preserveAspectRatio="none" overflow="hidden">'
|
||||
f'<image href="{href}" x="0" y="0" width="1" height="1" '
|
||||
f'preserveAspectRatio="none"{opacity_attr}/>'
|
||||
f"</svg>"
|
||||
)
|
||||
|
||||
media: dict[str, bytes] = {}
|
||||
if not embed_inline:
|
||||
media[filename] = img_bytes
|
||||
return PictureResult(
|
||||
svg=svg,
|
||||
media=media,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def convert_picture(
|
||||
pic_elem: ET.Element,
|
||||
xfrm: Xfrm,
|
||||
slide_part: PartRef,
|
||||
pkg: OoxmlPackage,
|
||||
*,
|
||||
media_subdir: str = "assets",
|
||||
embed_inline: bool = False,
|
||||
asset_name_map: dict[str, str] | None = None,
|
||||
strict: bool = False,
|
||||
) -> PictureResult:
|
||||
"""Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
|
||||
blip_fill = pic_elem.find("p:blipFill", NS)
|
||||
if blip_fill is None:
|
||||
return PictureResult()
|
||||
|
||||
return convert_blip_fill(
|
||||
blip_fill, xfrm, slide_part, pkg,
|
||||
media_subdir=media_subdir,
|
||||
embed_inline=embed_inline,
|
||||
asset_name_map=asset_name_map,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _blip_opacity_attr(
|
||||
blip: ET.Element,
|
||||
) -> tuple[str, tuple[PictureDiagnostic, ...]]:
|
||||
"""Translate DrawingML fixed image alpha to an SVG opacity attribute."""
|
||||
alpha_effects = blip.findall("a:alphaModFix", NS)
|
||||
if not alpha_effects:
|
||||
return "", ()
|
||||
if len(alpha_effects) > 1:
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
"duplicate a:alphaModFix effects cannot be reproduced safely"
|
||||
),
|
||||
)
|
||||
alpha = alpha_effects[0]
|
||||
try:
|
||||
opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0
|
||||
except ValueError:
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}"
|
||||
),
|
||||
)
|
||||
if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
|
||||
return "", (
|
||||
_effect_diagnostic(
|
||||
f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}"
|
||||
),
|
||||
)
|
||||
if opacity >= 1.0:
|
||||
return "", ()
|
||||
return f' opacity="{format_ooxml_alpha(opacity)}"', ()
|
||||
|
||||
|
||||
def _effect_diagnostic(message: str) -> PictureDiagnostic:
|
||||
return PictureDiagnostic(
|
||||
code="image-effect-omitted",
|
||||
message=message,
|
||||
fallback="retain the source image and omit only this image effect",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported_blip_effect_diagnostics(
|
||||
blip: ET.Element,
|
||||
) -> tuple[PictureDiagnostic, ...]:
|
||||
"""Report direct a:blip effects outside the implemented subset."""
|
||||
supported_tags = {
|
||||
f"{{{NS['a']}}}lum",
|
||||
f"{{{NS['a']}}}alphaModFix",
|
||||
f"{{{NS['a']}}}extLst",
|
||||
}
|
||||
unsupported = sorted({
|
||||
child.tag.rsplit("}", 1)[-1]
|
||||
for child in blip
|
||||
if child.tag not in supported_tags
|
||||
})
|
||||
diagnostics = (
|
||||
[
|
||||
_effect_diagnostic(
|
||||
"unsupported direct a:blip effect(s): "
|
||||
+ ", ".join(f"a:{name}" for name in unsupported)
|
||||
)
|
||||
]
|
||||
if unsupported
|
||||
else []
|
||||
)
|
||||
if len(blip.findall("a:lum", NS)) > 1:
|
||||
diagnostics.append(
|
||||
_effect_diagnostic(
|
||||
"duplicate a:lum effects cannot be reproduced safely"
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
|
||||
|
||||
|
||||
def _is_valid_svg_media(data: bytes) -> bool:
|
||||
"""Return whether one media part is a namespaced SVG document."""
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return False
|
||||
return root.tag == "{http://www.w3.org/2000/svg}svg"
|
||||
|
||||
|
||||
def _normalize_office_media(filename: str, img_bytes: bytes) -> tuple[str, bytes]:
|
||||
"""Convert Office-only vector image formats to browser-renderable PNG.
|
||||
|
||||
PPTX can contain EMF/WMF assets that PowerPoint renders natively but SVG
|
||||
viewers generally do not. Keep the original asset in the manifest layer;
|
||||
the SVG view uses a PNG preview when the local system can make one.
|
||||
"""
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in _OFFICE_VECTOR_EXTS:
|
||||
return filename, img_bytes
|
||||
|
||||
converted = _convert_office_vector_to_png(filename, img_bytes)
|
||||
if converted is None:
|
||||
return filename, img_bytes
|
||||
stem = Path(filename).stem
|
||||
return f"{stem}_preview.png", converted
|
||||
|
||||
|
||||
def _convert_office_vector_to_png(filename: str, img_bytes: bytes) -> bytes | None:
|
||||
magick = shutil.which("magick")
|
||||
if not magick:
|
||||
return None
|
||||
suffix = Path(filename).suffix.lower() or ".bin"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
src = tmp_dir / f"source{suffix}"
|
||||
dst = tmp_dir / "preview.png"
|
||||
src.write_bytes(img_bytes)
|
||||
try:
|
||||
subprocess.run(
|
||||
[magick, str(src), str(dst)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
if not dst.exists():
|
||||
return None
|
||||
return dst.read_bytes()
|
||||
|
||||
def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float] | None:
|
||||
"""Convert <a:srcRect l t r b="1/100000"/> to (x, y, w, h) in unit space."""
|
||||
if elem is None:
|
||||
return None
|
||||
if not (elem.attrib.keys() & {"l", "t", "r", "b"}):
|
||||
return None
|
||||
l = _pct_attr(elem, "l")
|
||||
t = _pct_attr(elem, "t")
|
||||
r = _pct_attr(elem, "r")
|
||||
b = _pct_attr(elem, "b")
|
||||
# All zero -> equivalent to no crop
|
||||
if l == 0 and t == 0 and r == 0 and b == 0:
|
||||
return None
|
||||
vb_x = l
|
||||
vb_y = t
|
||||
vb_w = max(0.0, 1.0 - l - r)
|
||||
vb_h = max(0.0, 1.0 - t - b)
|
||||
if vb_w <= 0 or vb_h <= 0:
|
||||
return None
|
||||
return vb_x, vb_y, vb_w, vb_h
|
||||
|
||||
|
||||
def _single_cover_tile_crop(
|
||||
tile: ET.Element,
|
||||
blip_fill: ET.Element,
|
||||
xfrm: Xfrm,
|
||||
img_bytes: bytes,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Map one oversized DrawingML tile to a unit-image crop window.
|
||||
|
||||
The closed SVG/PPTX crop transport cannot express a repeating pattern. It
|
||||
can, however, reproduce a tile when the aligned first tile alone covers the
|
||||
complete frame. This is how PowerPoint commonly stores full-page bitmap
|
||||
backgrounds without distorting their aspect ratio.
|
||||
"""
|
||||
if Image is None or xfrm.w <= 0 or xfrm.h <= 0:
|
||||
return None
|
||||
if tile.attrib.get("flip", "none") != "none":
|
||||
return None
|
||||
|
||||
natural_size = _image_size_at_96_dpi(img_bytes, blip_fill)
|
||||
if natural_size is None:
|
||||
return None
|
||||
natural_w, natural_h = natural_size
|
||||
scale_x = _pct_attr_default(tile, "sx", 1.0)
|
||||
scale_y = _pct_attr_default(tile, "sy", 1.0)
|
||||
tile_w = natural_w * scale_x
|
||||
tile_h = natural_h * scale_y
|
||||
if tile_w <= 0 or tile_h <= 0:
|
||||
return None
|
||||
|
||||
align_x, align_y = _tile_alignment(tile.attrib.get("algn", "tl"))
|
||||
tile_x = (
|
||||
xfrm.x
|
||||
+ (xfrm.w - tile_w) * align_x
|
||||
+ emu_to_px(tile.attrib.get("tx"))
|
||||
)
|
||||
tile_y = (
|
||||
xfrm.y
|
||||
+ (xfrm.h - tile_h) * align_y
|
||||
+ emu_to_px(tile.attrib.get("ty"))
|
||||
)
|
||||
|
||||
tolerance = 1e-4
|
||||
if (
|
||||
tile_x > xfrm.x + tolerance
|
||||
or tile_y > xfrm.y + tolerance
|
||||
or tile_x + tile_w < xfrm.x + xfrm.w - tolerance
|
||||
or tile_y + tile_h < xfrm.y + xfrm.h - tolerance
|
||||
):
|
||||
return None
|
||||
|
||||
crop = (
|
||||
(xfrm.x - tile_x) / tile_w,
|
||||
(xfrm.y - tile_y) / tile_h,
|
||||
xfrm.w / tile_w,
|
||||
xfrm.h / tile_h,
|
||||
)
|
||||
if all(
|
||||
abs(actual - expected) <= 1e-7
|
||||
for actual, expected in zip(crop, (0.0, 0.0, 1.0, 1.0))
|
||||
):
|
||||
return None
|
||||
return crop
|
||||
|
||||
|
||||
def _image_size_at_96_dpi(
|
||||
img_bytes: bytes,
|
||||
blip_fill: ET.Element,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Return the bitmap's physical size in the SVG canvas' 96-DPI pixels."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(img_bytes)) as image:
|
||||
pixel_w, pixel_h = image.size
|
||||
embedded_dpi = image.info.get("dpi")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
configured_dpi = _positive_float(blip_fill.attrib.get("dpi"))
|
||||
if configured_dpi is not None:
|
||||
dpi_x = dpi_y = configured_dpi
|
||||
else:
|
||||
dpi_x, dpi_y = _dpi_pair(embedded_dpi)
|
||||
return pixel_w * 96.0 / dpi_x, pixel_h * 96.0 / dpi_y
|
||||
|
||||
|
||||
def _dpi_pair(value: object) -> tuple[float, float]:
|
||||
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
||||
dpi_x = _positive_float(value[0]) or 96.0
|
||||
dpi_y = _positive_float(value[1]) or 96.0
|
||||
return dpi_x, dpi_y
|
||||
dpi = _positive_float(value) or 96.0
|
||||
return dpi, dpi
|
||||
|
||||
|
||||
def _positive_float(value: object) -> float | None:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _pct_attr_default(elem: ET.Element, name: str, default: float) -> float:
|
||||
value = elem.attrib.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value) / 100000.0
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _tile_alignment(value: str) -> tuple[float, float]:
|
||||
alignments = {
|
||||
"tl": (0.0, 0.0),
|
||||
"t": (0.5, 0.0),
|
||||
"tr": (1.0, 0.0),
|
||||
"l": (0.0, 0.5),
|
||||
"ctr": (0.5, 0.5),
|
||||
"r": (1.0, 0.5),
|
||||
"bl": (0.0, 1.0),
|
||||
"b": (0.5, 1.0),
|
||||
"br": (1.0, 1.0),
|
||||
}
|
||||
return alignments.get(value, alignments["tl"])
|
||||
|
||||
|
||||
def _apply_blip_image_effects(
|
||||
filename: str,
|
||||
img_bytes: bytes,
|
||||
blip: ET.Element,
|
||||
) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]:
|
||||
"""Bake supported DrawingML blip effects into extracted image bytes.
|
||||
|
||||
Brightness and contrast are pixel operations, not picture-shape
|
||||
shadow/glow effects, so preserve them in the extracted bitmap.
|
||||
"""
|
||||
lum_effects = blip.findall("a:lum", NS)
|
||||
if not lum_effects:
|
||||
return filename, img_bytes, ()
|
||||
if len(lum_effects) > 1:
|
||||
return filename, img_bytes, ()
|
||||
lum = lum_effects[0]
|
||||
|
||||
values: dict[str, float | None] = {}
|
||||
for name in ("bright", "contrast"):
|
||||
raw_value = lum.attrib.get(name)
|
||||
if raw_value is None:
|
||||
values[name] = None
|
||||
continue
|
||||
try:
|
||||
value = float(raw_value) / 100000.0
|
||||
except ValueError:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"),
|
||||
)
|
||||
if not math.isfinite(value) or not -1.0 <= value <= 1.0:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
f"out-of-range a:lum {name}={raw_value!r}"
|
||||
),
|
||||
)
|
||||
values[name] = value
|
||||
bright = values["bright"]
|
||||
contrast = values["contrast"]
|
||||
if bright is None and contrast is None:
|
||||
return filename, img_bytes, ()
|
||||
if Image is None or ImageEnhance is None:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
"a:lum requires Pillow, but Pillow is unavailable"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(img_bytes)) as source_image:
|
||||
if getattr(source_image, "is_animated", False):
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(
|
||||
"a:lum on an animated image would flatten its frames"
|
||||
),
|
||||
)
|
||||
output_format = (
|
||||
source_image.format or _pil_format_from_filename(filename)
|
||||
)
|
||||
image = source_image.copy()
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
if bright is not None:
|
||||
image = ImageEnhance.Brightness(image).enhance(max(0.0, 1.0 + bright))
|
||||
if contrast is not None:
|
||||
image = ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 + contrast))
|
||||
|
||||
out = io.BytesIO()
|
||||
save_format = output_format or "PNG"
|
||||
save_kwargs = {"quality": 95} if save_format.upper() in {"JPEG", "JPG"} else {}
|
||||
image.save(out, format=save_format, **save_kwargs)
|
||||
effect_key = f"lum-{bright}-{contrast}".encode("ascii")
|
||||
digest = hashlib.sha1(effect_key).hexdigest()[:8]
|
||||
return (
|
||||
_effect_filename(filename, digest, save_format),
|
||||
out.getvalue(),
|
||||
(),
|
||||
)
|
||||
except (KeyError, OSError, ValueError) as exc:
|
||||
return filename, img_bytes, (
|
||||
_effect_diagnostic(f"a:lum could not be rendered: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
def _pil_format_from_filename(filename: str) -> str | None:
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext in {"jpg", "jpeg"}:
|
||||
return "JPEG"
|
||||
if ext == "png":
|
||||
return "PNG"
|
||||
if ext == "gif":
|
||||
return "GIF"
|
||||
if ext == "webp":
|
||||
return "WEBP"
|
||||
return None
|
||||
|
||||
|
||||
def _effect_filename(filename: str, digest: str, image_format: str) -> str:
|
||||
stem, sep, ext = filename.rpartition(".")
|
||||
if not sep:
|
||||
ext = (image_format or "png").lower()
|
||||
stem = filename
|
||||
if ext.lower() == "jpg":
|
||||
ext = "jpeg"
|
||||
return f"{stem}_fx_{digest}.{ext}"
|
||||
|
||||
|
||||
def _pct_attr(elem: ET.Element, name: str) -> float:
|
||||
val = elem.attrib.get(name)
|
||||
if val is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(val) / 100000.0
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str:
|
||||
"""Build an <image href=...> value (relative path or data URI).
|
||||
|
||||
The path is relative to the SVG file's location. The slide assembler writes
|
||||
SVGs to <output>/svg/, so media files in <output>/<subdir>/ resolve via
|
||||
a leading "../".
|
||||
"""
|
||||
if embed:
|
||||
mime = (
|
||||
mimetypes.guess_type(filename)[0]
|
||||
or _sniff_mime(img_bytes)
|
||||
or "application/octet-stream"
|
||||
)
|
||||
encoded = base64.b64encode(img_bytes).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
rel = f"../{subdir}/{filename}" if subdir else f"../{filename}"
|
||||
return rel
|
||||
|
||||
|
||||
def _sniff_mime(data: bytes) -> str | None:
|
||||
"""Best-effort MIME sniffing for embedded images."""
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
||||
return "image/gif"
|
||||
if data.startswith(b"<svg") or data.startswith(b"<?xml"):
|
||||
return "image/svg+xml"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
return None
|
||||
Reference in New Issue
Block a user