BREAKING CHANGE: announcement: https://github.com/lwouis/alt-tab-macos/discussions/5533 * improved performance, especially switcher responsiveness * reduced battery usage even more * reduced ram usage (closes #5450, closes #5539, closes #5627) * reduced app size even more * polished many aspects of the ui; align more with liquid glass * better handle "ghost" windows (closes #5509) * fix issue with wrong window order (closes #5492) * escape closes the switcher on tahoe (closes #5585) * improve search matches (closes #5488) * localizations trimmed and reviewed entirely (closes #5583) * highlight matching app icons when searching, in addition to text * better settings import/export * reworked "send feedback" experience * reworked exceptions ui (closes #5482) * per-shortcut settings (closes #5313) * rework about window
192 lines
7.9 KiB
Python
Executable File
192 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Strip Figma-injected bloat from PDF assets while preserving rendering.
|
|
|
|
Drops the following without affecting how AppKit renders the PDF:
|
|
- /Info dict (Producer="Figma", Title=...) at both trailer and Catalog levels
|
|
- /Metadata XMP packet
|
|
- /StructTreeRoot, /ParentTree, /StructElem accessibility tags
|
|
- /Lang, /MarkInfo (page accessibility hints)
|
|
- Per-page /Annots, /StructParents, /Tabs, /Metadata
|
|
- /ProcSet [/PDF] (deprecated since PDF 1.4)
|
|
- Embedded ICC color profiles: every [/ICCBased N R] reference is replaced with
|
|
/DeviceRGB or /DeviceGray, walking page Resources, Form XObject Resources,
|
|
Image XObject /ColorSpace, and Pattern /Shading /ColorSpace dicts. This
|
|
matters most for Figma's color icons (8+ patterns each referencing the same
|
|
ICC profile object).
|
|
|
|
Usage:
|
|
python3 scripts/assets/optimize_figma_pdf.py path1.pdf path2.pdf ...
|
|
|
|
The script edits each PDF in place. Run after exporting from Figma. For the
|
|
final 1-2% squeeze, follow with:
|
|
|
|
mutool clean -ggg -z in.pdf out.pdf
|
|
qpdf --object-streams=generate --recompress-flate --compression-level=9 out.pdf final.pdf
|
|
|
|
Requires: pip3 install --user --break-system-packages pikepdf
|
|
"""
|
|
import sys
|
|
|
|
try:
|
|
import pikepdf
|
|
from pikepdf import Name
|
|
except ImportError:
|
|
sys.stderr.write("pikepdf not installed. Run: pip3 install --user --break-system-packages pikepdf\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def swap_icc_in_cs_dict(cs_dict):
|
|
"""Walk a ColorSpace dict; replace ICCBased entries with /DeviceRGB or /DeviceGray."""
|
|
try:
|
|
keys = list(cs_dict.keys())
|
|
except Exception:
|
|
return
|
|
for key in keys:
|
|
try:
|
|
val = cs_dict[key]
|
|
arr = list(val) # auto-dereferences indirect refs
|
|
if len(arr) >= 2 and str(arr[0]) == '/ICCBased':
|
|
icc = arr[1]
|
|
n = int(icc.get('/N', 1)) if hasattr(icc, 'get') else 1
|
|
cs_dict[key] = Name('/DeviceGray' if n == 1 else ('/DeviceRGB' if n == 3 else '/DeviceCMYK'))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def optimize(path):
|
|
before = open(path, 'rb').read()
|
|
pdf = pikepdf.open(path, allow_overwriting_input=True)
|
|
|
|
# Wipe document info dict — trailer level
|
|
if pdf.docinfo is not None:
|
|
for k in list(pdf.docinfo.keys()):
|
|
del pdf.docinfo[k]
|
|
|
|
# Strip catalog-level metadata + accessibility + inline /Info
|
|
root = pdf.Root
|
|
for k in ['/Metadata', '/StructTreeRoot', '/Lang', '/MarkInfo', '/Info',
|
|
'/ViewerPreferences', '/PageLayout', '/PageMode', '/AcroForm',
|
|
'/Outlines', '/Names', '/PageLabels', '/OpenAction']:
|
|
if Name(k) in root:
|
|
del root[Name(k)]
|
|
|
|
# Walk every object — including Streams (Image and Form XObjects are streams,
|
|
# not plain Dictionary). Patch every ColorSpace ref to swap ICC → Device*.
|
|
for obj in pdf.objects:
|
|
try:
|
|
if not isinstance(obj, (pikepdf.Dictionary, pikepdf.Stream)):
|
|
continue
|
|
|
|
# Page-level cruft
|
|
if obj.get(Name('/Type')) == Name('/Page'):
|
|
for k in ['/Annots', '/StructParents', '/Tabs', '/Metadata']:
|
|
if Name(k) in obj:
|
|
del obj[Name(k)]
|
|
|
|
subtype = obj.get(Name('/Subtype')) if Name('/Subtype') in obj else None
|
|
|
|
# Image XObject: /ColorSpace may be a direct [/ICCBased ...] array or
|
|
# an indirect ref to one. list(cs) auto-dereferences in pikepdf.
|
|
if subtype == Name('/Image'):
|
|
cs = obj.get(Name('/ColorSpace'))
|
|
if cs is not None:
|
|
try:
|
|
arr = list(cs)
|
|
if len(arr) >= 2 and str(arr[0]) == '/ICCBased':
|
|
n = int(arr[1].get('/N', 3)) if hasattr(arr[1], 'get') else 3
|
|
obj[Name('/ColorSpace')] = Name('/DeviceGray' if n == 1 else '/DeviceRGB')
|
|
except Exception:
|
|
pass
|
|
|
|
# Form XObject: has its own Resources
|
|
if subtype == Name('/Form') and Name('/Resources') in obj:
|
|
fres = obj[Name('/Resources')]
|
|
if Name('/ColorSpace') in fres:
|
|
swap_icc_in_cs_dict(fres[Name('/ColorSpace')])
|
|
if Name('/ProcSet') in fres:
|
|
del fres[Name('/ProcSet')]
|
|
|
|
# Page Resources
|
|
res = obj.get(Name('/Resources'))
|
|
if res is not None and isinstance(res, pikepdf.Dictionary):
|
|
if Name('/ColorSpace') in res:
|
|
swap_icc_in_cs_dict(res[Name('/ColorSpace')])
|
|
if Name('/ProcSet') in res:
|
|
del res[Name('/ProcSet')]
|
|
# Patterns may have Shading dicts whose /ColorSpace points at ICC.
|
|
if Name('/Pattern') in res:
|
|
for pkey in list(res[Name('/Pattern')].keys()):
|
|
pat = res[Name('/Pattern')][pkey]
|
|
if not isinstance(pat, pikepdf.Dictionary):
|
|
continue
|
|
sh = pat.get(Name('/Shading'))
|
|
if sh is not None and isinstance(sh, pikepdf.Dictionary):
|
|
cs = sh.get(Name('/ColorSpace'))
|
|
if cs is not None:
|
|
try:
|
|
arr = list(cs)
|
|
if len(arr) >= 2 and str(arr[0]) == '/ICCBased':
|
|
n = int(arr[1].get('/N', 3)) if hasattr(arr[1], 'get') else 3
|
|
sh[Name('/ColorSpace')] = Name('/DeviceGray' if n == 1 else '/DeviceRGB')
|
|
except Exception:
|
|
pass
|
|
if Name('/Resources') in pat:
|
|
patres = pat[Name('/Resources')]
|
|
if Name('/ColorSpace') in patres:
|
|
swap_icc_in_cs_dict(patres[Name('/ColorSpace')])
|
|
except Exception:
|
|
pass
|
|
|
|
pdf.remove_unreferenced_resources()
|
|
pdf.save(path,
|
|
compress_streams=True,
|
|
stream_decode_level=pikepdf.StreamDecodeLevel.specialized,
|
|
object_stream_mode=pikepdf.ObjectStreamMode.generate,
|
|
linearize=False,
|
|
min_version='1.4')
|
|
after = open(path, 'rb').read()
|
|
pct = (len(before) - len(after)) * 100 // len(before) if len(before) else 0
|
|
print(f"{path}: {len(before)} -> {len(after)} ({pct}% saved)")
|
|
|
|
# Bitmap-leak alarm: every Image XObject left in the file means Figma rasterized
|
|
# something it could have kept vector. Usually the source is a stroked path (fix:
|
|
# ⌘⇧O Outline Stroke) or a layer effect (drop shadow, blur, blend mode). Hidden
|
|
# layers can still produce bitmaps when referenced by a clipping/masking group —
|
|
# delete them rather than just hiding. See .claude/skills/assets-optimization/SKILL.md.
|
|
bitmaps = count_images(path)
|
|
if bitmaps:
|
|
print(f" ⚠ WARNING: {bitmaps} embedded bitmap(s) found — Figma source needs cleanup,"
|
|
f" optimizer can't fix this. Investigate the .fig file.", file=sys.stderr)
|
|
return False
|
|
return True
|
|
|
|
|
|
def count_images(path):
|
|
"""Return the number of Image XObjects inside the PDF."""
|
|
pdf = pikepdf.open(path)
|
|
count = 0
|
|
for obj in pdf.objects:
|
|
try:
|
|
if isinstance(obj, (pikepdf.Dictionary, pikepdf.Stream)):
|
|
if obj.get(Name('/Subtype')) == Name('/Image'):
|
|
count += 1
|
|
except Exception:
|
|
pass
|
|
return count
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
any_bitmaps = False
|
|
for path in sys.argv[1:]:
|
|
if not optimize(path):
|
|
any_bitmaps = True
|
|
# Non-zero exit when any input still has a bitmap so CI/scripts can catch it.
|
|
sys.exit(1 if any_bitmaps else 0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|