我为什么做 Tweet to Image —— 以及差点让它夭折的那个细节
我做这个东西,是因为要把一条推文放进一张幻灯片,结果花了四十分钟也没弄好。整个起因就这一句。剩下的都是后来发生的事,包括我差点把仓库删掉的那一周。
那四十分钟
一条三行的推文、一张幻灯片,以及把前者放进后者的每一条路都差那么一点。
我先用手机截了图。1170px 的手机屏,推文大概占一半,外面裹着我自己的时间线:下面的回复框、露出头的下一条推文、角落里我的头像、剩 23% 的电量。
那就裁。裁完是 900px 的发糊灰字压在一个白矩形上,放在深色背景的幻灯片里,外面套着一个我去不掉的白框。我又在笔记本上试了一次,更锐一点,还是白框。于是我打开 Figma,开始手工重画这张卡片 —— 名字、用户名、时间、下面那排小图标。
就是在那一刻我停下了。如果我这次要手工排一遍,那我以后每次都得手工排一遍,而且每次都会排得不太一样。所以我关掉 Figma,写了这个工具的第一版。
第一版能跑,而且是错的
一个下午就能渲染出一条推文。它也确凿无疑地像「一张推文的画」。
我给谁看,谁都说同一句没用但正确的话:「看着有点怪。」没人说得出为什么,包括我自己。搞清楚「怪」其实是四个各自独立的数值,花的时间比我愿意承认的长。
- X 桌面端的正文栏是 598px,不是「六百左右」。多二十个像素,换行位置就变了 —— 你先感觉到,之后才看出来。
- 头像是 40px,不是 48。48 是你会猜的那个数,而 48 会让卡片看起来像一张工单。
- 显示名称、蓝V、@用户名和时间全在同一行上,而且显示名称比用户名先被截断。把它们摞起来,读起来就是博客评论,不是 X 上的推文。
- 操作栏在正文下方,上面留白是有具体数值的,图标也比你直觉认为的要小。间距错了,卡片就会显得头轻脚重。
差点夭折的那一周
字体我对不上,有几天我以为这就意味着「这里根本没有产品」。
X 的正文用的是一款专有字体。我打包不了它,也不打算在字形明显不一样的情况下宣称「像素级还原」,而且这件事没有什么聪明的绕法。那大约一周里,这个项目就那么停在「对了 90%」的状态上 —— 而正是那 90% 让剩下的 10% 变得无法忍受。
然后我去看了中文是怎么回事。X 的正文字体根本不含 CJK 字形 —— 也就是说,X 上的中文本来就在 fallback 到系统字体:苹果设备上是 PingFang SC,其他平台是 Noto Sans SC。如果我用同一条 fallback 链,中文推文就不是「大致正确」,而是同一种渲染 —— 因为浏览器做的事,和 X 那边的浏览器做的事一模一样。
这件事把整个项目重新定了性。诚实的说法从来就不是「像素级还原」,而是:在浏览器能达到的范围内尽量接近,并且在由浏览器说了算的地方完全一致。所以我改的是说法,不是产品,把这条局限写进了关于页让任何人都能读到,然后发布。
我觉得这是这篇文章里最有用的一条。失败的不是技术,是我给自己许了一个兑现不了的说法;而解法是不再许那个说法,而不是继续在字体上磨。
花时间最长、也最要紧的那个细节
零。在 X 上,一条没人转发的推文不显示 0 —— 它只显示图标,别的什么都没有。
我的第一版在每张卡片下面都端端正正写了个 0,这是最大的一个破绽 —— 现在互动数据把 0 当作「隐藏这个数字」。修掉之后,同一类细节接二连三地冒出来:
- 过长的链接按 X 的方式截断显示,而不是一路顶到卡片边缘。
- 段落之间的空行原样保留。X 会保留它们,而多数重建版本会把它们压掉 —— 段落被压掉是仅次于「零」的第二大破绽。正文是怎么处理的里讲了正文栏还会自己做哪些事。
- 时间是自由文本,不是日期选择器。你描述的是卡片该显示什么 ——「14h」「3小时」「9月6日」「14小时」—— 而不是它什么时候发的。做日期选择器代码更多,用处更少。
- 三种主题,因为暗蓝(#15202B)是存在的,而且用它的人多得出人意料。深色深错了,和字体用错了一样错。
我最不自在的那部分
一个能做出可信推文卡片的工具,就是一个能做出可信假推文的工具。这个项目不存在让这句话不成立的版本。
我想过不发布。反对不发布的理由并不让人舒服,但我认为它是对的:这个能力在每个截图工具、每个图像编辑器和大约四十个别的网站里都已经存在了。压下这一个不会让这个能力消失,只会让「试图守规矩的那一版」消失。
所以它实际上做了这些事。每一张导出的 PNG 都在文件元数据里带一条声明,标明这是合成图像;它肉眼不可见、不改变画面的任何一个像素,任何标准图片工具都能读出来。工具不从 X 抓取任何东西 —— 文字是你自己敲进去的,所以不存在「贴个链接就得到一份真推文的完美复制」这条路,而那条路才是让批量伪造变容易的路。服务条款禁止冒充他人,也禁止去掉这条元数据。
还有边界,因为只说前半句是不诚实的:元数据是可以被剥掉的。真想干的人一条命令就能删掉。它是一个减速带和一个态度声明,不是一个保证。比起宣称一个我根本无法执行的保证,我宁愿做一个减速带,并且如实说它就是个减速带。
为什么没有服务器
因为另一条路意味着我要保管一堆别人说过的话,而我不想要它。
一切都在你的浏览器里渲染。卡片就是普通的 HTML 和 CSS,导出就是对同一个页面的一次截取。出图链路上没有图片服务器,也没有任何 API 调用。
带来的结果几乎全是好的。页面加载完之后断网也照样能用。运行成本基本为零,所以它可以真的免费,而不是「先免费以后再说」。它泄不了你输入的东西,因为你输入的东西从没离开过那个标签页 —— 这也是隐私政策很短的原因:实在没多少可披露的。
代价是浏览器差异得我自己扛。比如 Safari 的剪贴板要求写入必须和你的点击处在同一个任务里,所以渲染结果得作为一个 Promise 交出去,而不能先 await。这个问题曾经存在过,永远不会有人察觉 —— 而这正是重点。
第二件差点没成的事
站点当时是个单页应用,抓取器来了只看到一个空 div。
有一段时间首页是纯客户端 React。人看到的是完整页面,Googlebot 大致也能应付。但 GPTBot、ClaudeBot、PerplexityBot 这些不执行 JavaScript 的抓取器,看到的是一个空的根元素,别的什么都没有:没有标题、没有描述、没有任何可以引用的东西。
一个用抓取器读不到的文字来解释自己的工具,是一个没人找得到的工具。解法是在构建时把页面渲染成真正的 HTML —— 一点也不光彩,花了一个下午,对「有多少人能走到这里」的作用比我在卡片本身上做的任何事都大。
不打算加的东西
推文串、媒体、引用推文、投票。
不是因为难,而是因为每加一样,「可信的假货」的面积就大一圈。单张纯文字卡片是一句带着头像的引文;一整条配了图的伪造推文串是另一种东西,我不想成为让那件事变容易的人。
还有一个不那么高尚的理由:单卡片这一版就是我当初为那张幻灯片需要的东西,而它已经做完了。做完的工具很少见,我挺想有一个。
如果你也在做类似的东西
四条我想对开工那天的自己说的话。
- 去找出那四个数。 每一句「看着有点怪」,背后都是一小组具体错掉的数值。一张截图加一把尺子,一个小时能找出来;不找的话,你会靠猜花掉一周。
- 发布诚实的那个说法,不是理想的那个。 更快,更耐放,而且一年后你还站得住。
- 如果你做的东西可能被滥用,就在页面上说出来。 把你真正能做的那一件具体的事做掉,同时同样清楚地说明它覆盖不到什么。
- 在构建时把 HTML 渲染出来。 现在最要紧的那批抓取器,恰恰是不跑 JavaScript 的那批。
这一切最后做出来的东西就在首页,免费。
这篇用到的设置
这篇文章依赖的使用说明小节:
继续阅读
- 各设备怎么给推文截图截图动作两秒钟就完了,难的是让结果能见人 —— 而这部分几乎没人写。这里有每个设备的确切快捷键、一个能按任意分辨率单独截下一条推文的浏览器技巧,以及原始截图放进幻灯片或信息流后露怯的六个原因。
- 把推文截图发到 Instagram:尺寸、裁切,和一套能重复用的流程Instagram 按 1080px 宽显示、把信息流裁到 4:5、并把你上传的一切重新编码成 JPEG。手机截推文这三条一次全踩。这里给出能用的数字、为什么 2 倍导出恰好是对的尺寸,以及怎么让十二张的轮播看起来像同一套。