-
Notifications
You must be signed in to change notification settings - Fork 1
feat(ui): show embedded post images instead of raw base64 #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; | ||
|
|
||
| function renderSegment(segment: PostBodySegment, index: number) { | ||
| switch (segment.kind) { | ||
| case "text": | ||
| return ( | ||
| <p key={`post-body-text-${index}`} className="post-body-text"> | ||
| {segment.text} | ||
| </p> | ||
| ); | ||
| case "image": | ||
| return ( | ||
| <figure key={`post-body-image-${index}`} className="post-embedded-image"> | ||
| <img | ||
| src={segment.src} | ||
| alt={`Embedded image at character offset ${segment.position}`} | ||
| /> | ||
| <figcaption> | ||
| Image from this post. Extract Keyman or ask a question to read text | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This caption is not an action the current viewer can take.
So the default stack shows a picture and tells the operator to read text inside it via a path that is either hidden or a missing channel. Reword to what this screen actually does. |
||
| inside it. | ||
| </figcaption> | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| </figure> | ||
| ); | ||
| default: { | ||
| const _exhaustive: never = segment; | ||
| throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function PostBody({ body }: { body: string }) { | ||
| return <div className="post-body">{splitPostBody(body).map(renderSegment)}</div>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { splitPostBody } from "./postBodyDisplay"; | ||
|
|
||
| /** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */ | ||
| const TINY_PNG_B64 = | ||
| "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; | ||
|
|
||
| describe("splitPostBody", () => { | ||
| it("leaves a plain-text post unchanged so existing popups keep their wording", () => { | ||
| expect(splitPostBody("The full body text.")).toEqual([ | ||
| { kind: "text", text: "The full body text." }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("keeps comparison operators that look like broken HTML", () => { | ||
| expect(splitPostBody("qty < 50 and price > 10")).toEqual([ | ||
| { kind: "text", text: "qty < 50 and price > 10" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("renders a data-URI image as its own segment and never leaks the raw base64 into text", () => { | ||
| const html = | ||
| `<p>Quote attached.</p><img src="data:image/png;base64,${TINY_PNG_B64}" alt=""><p>Please confirm.</p>`; | ||
| const segments = splitPostBody(html); | ||
|
|
||
| expect(segments).toEqual([ | ||
| { kind: "text", text: "Quote attached." }, | ||
| { | ||
| kind: "image", | ||
| src: `data:image/png;base64,${TINY_PNG_B64}`, | ||
| mimeType: "image/png", | ||
| position: html.indexOf("<img"), | ||
| }, | ||
| { kind: "text", text: "Please confirm." }, | ||
| ]); | ||
| for (const segment of segments) { | ||
| if (segment.kind === "text") { | ||
| expect(segment.text).not.toContain(TINY_PNG_B64); | ||
| expect(segment.text).not.toContain("data:image"); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it("keeps two images in document order when a paragraph sits between them", () => { | ||
| const html = | ||
| `<img src="data:image/png;base64,${TINY_PNG_B64}"><p>between</p>` + | ||
| `<img src="data:image/png;base64,${TINY_PNG_B64}">`; | ||
| const segments = splitPostBody(html); | ||
| expect(segments.map((segment) => segment.kind)).toEqual(["image", "text", "image"]); | ||
| expect(segments[1]).toEqual({ kind: "text", text: "between" }); | ||
| expect(segments[0]?.kind === "image" && segments[0].position).toBe(0); | ||
| expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it("tells the operator to re-export when the base64 payload is not decodable", () => { | ||
| const html = '<img src="data:image/png;base64,A">'; | ||
| expect(splitPostBody(html)).toEqual([ | ||
| { | ||
| kind: "text", | ||
| text: "Embedded image could not be decoded. Re-export the source post and open it again.", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("does not turn a remote http img into a loaded image", () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This remote case is necessary but too narrow: surrounding Missing fixtures that fail on this merged head:
|
||
| const html = '<p>See</p><img src="https://example.test/invoice.png"><p>end</p>'; | ||
| const segments = splitPostBody(html); | ||
| expect(segments.every((segment) => segment.kind === "text")).toBe(true); | ||
| expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( | ||
| "See", | ||
| ); | ||
| expect(JSON.stringify(segments)).not.toContain("https://example.test"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| /** | ||
| * Split a raw `post_body` into text and in-place data-URI images. | ||
| * | ||
| * The popup used to dump the source string, so a buyer who opened a post | ||
| * with an embedded invoice saw a base64 wall instead of the picture. | ||
| * Only `data:image/...;base64,...` payloads are turned into images — | ||
| * remote `http(s)` img tags are stripped, never fetched. | ||
| */ | ||
|
|
||
| export type PostBodySegment = | ||
| | { kind: "text"; text: string } | ||
| | { kind: "image"; src: string; mimeType: string; position: number }; | ||
|
|
||
| const DATA_URI_IMG = | ||
| /<img\b[^>]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; | ||
|
|
||
| const UNDECODEABLE_IMAGE = | ||
| "Embedded image could not be decoded. Re-export the source post and open it again."; | ||
|
|
||
| function stripHtmlTags(text: string): string { | ||
| return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); | ||
| } | ||
|
|
||
| function isDecodableBase64(raw: string): boolean { | ||
| if (raw.length === 0) { | ||
| return false; | ||
| } | ||
| try { | ||
| atob(raw); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Treat “decodable” as “the browser can paint this” (magic bytes, plus |
||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function pushText(segments: PostBodySegment[], raw: string): void { | ||
| const text = stripHtmlTags(raw); | ||
| if (text) { | ||
| segments.push({ kind: "text", text }); | ||
| } | ||
| } | ||
|
|
||
| export function splitPostBody(body: string): PostBodySegment[] { | ||
| const segments: PostBodySegment[] = []; | ||
| const pattern = new RegExp(DATA_URI_IMG.source, "gi"); | ||
| let lastIndex = 0; | ||
| let match = pattern.exec(body); | ||
| while (match !== null) { | ||
| pushText(segments, body.slice(lastIndex, match.index)); | ||
| const mimeType = match[1]; | ||
| const rawB64 = match[2].replace(/\s+/g, ""); | ||
| if (isDecodableBase64(rawB64)) { | ||
| segments.push({ | ||
| kind: "image", | ||
| src: `data:${mimeType};base64,${rawB64}`, | ||
| mimeType, | ||
| position: match.index, | ||
| }); | ||
| } else { | ||
| segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); | ||
| } | ||
| lastIndex = match.index + match[0].length; | ||
| match = pattern.exec(body); | ||
| } | ||
| pushText(segments, body.slice(lastIndex)); | ||
| if (segments.length === 0) { | ||
| return [{ kind: "text", text: body }]; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fallback re-dumps the raw I ran
Plain text already survives via |
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| return segments; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,4 +55,4 @@ | |
| "sentence_excerpts", | ||
| ] | ||
|
|
||
| __version__ = "0.86.0" | ||
| __version__ = "0.86.1" | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
“Extract Keyman or Ask still runs OCR on that image for search” is not true on the shipped default. Those paths call
normalize_post_bodywith_vision_client(), which is Null withoutVISION_MODELand then emits[image: content unavailable]. Extract is alsopost_admin-gated.Keep the changelog to what a buyer can do after
make seedwithout inventing a live vision channel.