sharethought 是做什么的?
sharethought 文件是一种有效的 PNG 文件,其在图像之外还携带了文本部分。它在任何地方都能像普通图像一样打开和渲染。图像背后携带了:用来渲染它的 SVG 源代码,以及一篇 Markdown 文档。携带数据前端的清单索引了所有内容,并将解包和重新打包的说明包含在文件自身之内。
这些部分可以通过两种载体之一传输:追加在 IEND 块之后(简单、仅适用于字节精确传输),或存放在 PNG 辅助块内(能存活于图像管线)。参见载体。
使用参考实现
本仓库中的 sharethought.py 是经过测试的、仅使用标准库的实现。建议优先使用它,而不是手动解析——每次重新实现都会引入新的潜在错误。
python3 sharethought.py pack --png base.png --svg s.svg --doc d.md -o out.png --what "一行描述"
python3 sharethought.py unpack out.png [--out-dir DIR]
python3 sharethought.py repack --text out.png --set doc=new.md -o out2.png
python3 sharethought.py verify out.png
python3 -m unittest -q test_sharethought # 测试套件
pack 在一次构建中同时写入两种载体(它们决不会出现不一致)。unpack 优先读取块,回退到尾部数据,并在两种载体同时存在但内容不一致时报错——pack 不会产生这种情况,所以不一致意味着外部篡改。verify 检查签名、IEND、两种载体、段名称的语法,以及清单与段之间的一致性。
格式(精确)
[ PNG 字节 ... IEND 块 ]
SHARETHOUGHT-v1\n
[ JSON 清单 — 一个 UTF-8 对象,参见模式 ]\n
<<<SECTION:name>>>\n
[ 段内容 — 默认为原始 UTF-8,如果 "enc":"base64" 则为 base64 编码 ]\n
<<<END:name>>>\n
... (更多段,相同的框架)
标记 SHARETHOUGHT-v1 紧跟在 PNG 的 IEND 块之后,并以换行符结束。(它前面的字节是 IEND 块的 CRC 尾部,不是换行符——所以应该按字节查找,而不是按行。)从标记到文件末尾的所有内容就是尾部数据。标准段包括:svg(图像源代码,image/svg+xml)和 doc(Markdown,text/markdown)。允许任意其他段——任何文本类型均可。
段名称必须匹配 [A-Za-z0-9_]+。 不符合此语法的名称在打包时会被拒绝,解包时会通过命名错误进行标记——不会静默地消失。
载体
相同的清单和段可以通过两种方式携带。根据传输方式选择。
A. 尾部数据 — 追加在 IEND 块之后(上述默认方式)。
- 手动编写非常简单:
cat trailer.txt >> file.png。无需 CRC、无需块框架、无需库。 - 仅能存活于字节精确传输:git、zip、
curl -O、直接文件复制。 - 不能存活于任何会重新序列化或重新编码图像的处理管线。PNG→JPEG 转码会将其完全丢弃。许多聊天和云存储管线会进行的块级重写会保留每个 IDAT 字节,但丢弃 IEND 之后的所有内容。
B. 辅助块 — iTXt 位于 PNG 块流内部。
- 存在于块列表内部,因此能存活于块级重写和保持 PNG 格式的图像管线。
- 需要 CRC 和块框架,大的段需要压缩。
- 不能存活于 PNG→JPEG 转码——除了像素之外,没有任何内容能幸存于这种转码。
建议:同时写入两种载体(pack 会这样做)。相同的负载,一次构建,不可能出现不一致。读取器会优先查找块,然后回退到尾部数据,因此文件既能在聊天管线上工作,也能在字节精确传输上工作。代价大约是额外复制一次负载。
块的布局:
[ IHDR ] [ IDAT ... ] [ iTXt sharethought:manifest ] [ iTXt sharethought:<name> ] ... [ IEND ]
每个段对应一个 iTXt:清单使用关键字 sharethought:manifest,每个段主体使用 sharethought:<name>(原始主体,无分隔符)。标准的 iTXt 框架——关键字 \0、压缩标志(1 字节,1 = zlib)、压缩方法(0)、语言标签 \0、翻译后的关键字 \0,然后是文本。
清单模式
{
"v": 1,
"what": "描述此工件的一行文本",
"how": {
"unpack": "如果存在名为 sharethought:* 的 iTXt 块(清单是 sharethought:manifest),则读取它们;否则从 SHARETHOUGHT-v1 标记读取到文件末尾的字节。解析 JSON 清单,然后解析每个段在其 <<<SECTION:name>>> 和 <<<END:name>>> 分隔符之间的内容。解码 base64 段。",
"repack_text_only": "保留原始 PNG 字节直到标记处,然后用更新后的清单和段重写尾部数据。无需重新渲染。或者运行:python3 sharethought.py repack --text FILE --set doc=new.md",
"repack_visual": "要更改图像:编辑 svg 段,将其渲染为 PNG(rsvg-convert -w 1200 in.svg > out.png,或 cairosvg,或 resvg,或通过 Playwright 使用 Chromium 等无头浏览器),然后再次打包。"
},
"sections": [
{ "name": "svg", "type": "image/svg+xml", "enc": "utf8", "summary": "图像的一行描述" },
{ "name": "doc", "type": "text/markdown", "enc": "utf8", "summary": "文档的一行描述" }
]
}
how 字段是必需的,并且必须包含有效的说明——该文件可以教会任何初次阅读者如何打开它。
解包
最佳方式:python3 sharethought.py unpack file.png。手动方式(尾部数据载体):
raw = open("file.png", "rb").read()
# 遍历 PNG 块直到真正的 IEND — 切勿使用 raw.index() 查找标记,
# 压缩的 IDAT 字节可能偶然包含该标记。
off = 8
while True:
import struct
ln, typ = struct.unpack(">I", raw[off:off+4])[0], raw[off+4:off+8]
if typ == b"IEND": break
off += 12 + ln
trailer = raw[off + 12:].decode("utf-8")
import json
manifest, _ = json.JSONDecoder().raw_decode(trailer[trailer.index("\n")+1:])
import re
for m in re.finditer(r"<<<SECTION:(\w+)>>>\n(.*?)\n<<<END:\1>>>", trailer, re.DOTALL):
name, body = m.group(1), m.group(2)
# 如果 enc 是 base64:import base64; body = base64.b64decode(body)
手动方式(块载体,优先检查):
import struct, zlib, json
raw = open("file.png", "rb").read()
off, chunks = 8, {}
while off + 8 <= len(raw):
ln = struct.unpack(">I", raw[off:off+4])[0]
typ = raw[off+4:off+8]
data = raw[off+8:off+8+ln]
if typ == b"IEND": break
if typ == b"iTXt":
key, _, rest = data.partition(b"\x00")
flag = rest[0]
rest = rest[2:]
_, _, rest = rest.partition(b"\x00") # 语言标签
_, _, rest = rest.partition(b"\x00") # 翻译后的关键字
if flag == 1:
rest = zlib.decompress(rest)
chunks[key.decode()] = rest.decode("utf-8")
off += 12 + ln
manifest = json.loads(chunks["sharethought:manifest"])
sections = {k[len("sharethought:"):]: v
for k, v in chunks.items()
if k.startswith("sharethought:") and k != "sharethought:manifest"}
仅使用 shell:tail -c +$(( $(grep -abo 'SHARETHOUGHT-v1' file.png | head -1 | cut -d: -f1) + 1 )) file.png 将尾部数据作为文本打印出来(仅在尾部数据未被传输丢弃时有效)。
创建
最佳方式:渲染 SVG,然后运行 python3 sharethought.py pack --png out.png --svg diagram.svg --doc notes.md -o artifact.png。手动步骤:
- 编写 SVG(面向人类的图片或图表)。
- 渲染为 PNG:
rsvg-convert -w 1200 diagram.svg > out.png(或cairosvg diagram.svg -o out.png,或resvg diagram.svg out.png,或使用无头浏览器——通过 Playwright 使用 Chromium——这通常是唯一已安装的 SVG 渲染器)。 - 编写 Markdown 文档。
- 追加尾部数据(类似于
cat trailer.txt >> file.png),并且最好也写入 iTXt 块——pack可以从一次构建中完成这两项。
输出文件可以像普通 PNG 一样渲染。验证:在任何图像查看器中打开它(显示图像),然后运行 strings artifact.png | grep SHARETHOUGHT(显示尾部数据存在),或运行 python3 sharethought.py verify artifact.png(检查所有内容,包括两种载体是否一致)。
重新打包
最佳方式:python3 sharethought.py repack --text artifact.png --set doc=new.md -o out.png——重写两种载体,无需渲染器。
仅文本编辑(文档更改,图像不变):重用原始 PNG 字节(SHARETHOUGHT-v1 之前的所有内容),使用编辑后的段和更新后的清单摘要写入新的尾部数据。如果文件也携带了块,也要重写它们——参考实现从同一个源重新构建两者,这是保持它们一致的唯一安全方法。
视觉编辑(svg 更改):将修改后的 SVG 渲染为新的 PNG,然后用新的 svg 段 + 文档 + 清单再次打包。任何 SVG 渲染器都可以使用,包括无头浏览器。
往返规则:重新打包的文件仍然是 sharethought 文件。相同的格式、相同的标记、更新后的清单。
分隔符安全性
段分隔符是单独成行的 <<<SECTION:name>>> 和 <<<END:name>>>。如果内容可能包含这样的行,则在清单中为该段设置 "enc":"base64" 并将主体编码为 base64——当主体包含其自身的结束分隔符或标记字符串时,pack 会自动执行此操作。
实际上哪种冲突会造成破坏:主体包含其他段的分隔符是无害的。主体包含其自身的 <<<END:name>>> 行会被非贪婪正则表达式截断——该行之后的所有内容都会成为孤儿。参考实现会检测到这种情况(孤儿内容和未平衡分隔符错误),而不是静默地截断。如有疑问,请使用 base64。
保持负载存活
负载不是 PNG 像素数据的一部分,因此任何重新编码图像的工具都可能静默地丢弃它,留下一个看起来完全正常但没有深层信息的 PNG 文件。这包括 Pillow 中的 Image.open(f).save(g)、图像编辑器和 CDN 优化器。应从原始字节重新打包,而不是重新保存渲染后的文件。
IEND 之后的尾部数据仅能存活于字节精确传输。如果文件必须通过图像管线——聊天附件、缩略图生成器、转码器——请使用块载体(pack 会这样做),并且以文件形式上传,而不是以内联图像形式上传,这样 PNG 不会被转码为 JPEG。
与人类协作
人类看到的是 PNG——简化的视觉层。所有深层信息(svg 源代码、文档、清单)对于普通查看是不可见的,但对你来说完全可用。当人类询问图像时,解包并读取文档以获取上下文。当人类想要更改图片时,编辑 svg 并重新打包视觉部分。当人类想要添加深层信息时,编辑文档并仅文本重新打包。文件就是整个交流——一个工件,两种模态,无需旁路通道。