Turn a picture of a slide into a real PowerPoint slide - in the browser, or from the command line.
Feed it a screenshot, a scanned page, a whiteboard photo or a diagram, and it
writes a .pptx whose shapes, connectors, tables and text boxes are native,
editable PowerPoint objects: you can click the table and retype a cell, drag
a box, restyle the header, re-point an arrow.
The one exception is an image element: a region the analyzer genuinely
cannot express as shape, table or text - a photo, a rendered chart, a logo -
is cropped out of the source image and embedded as a picture. That exception is
fenced, because pasting the input onto a slide would make the whole exercise
pointless. pptx_builder.flattening_report() is the single definition of
"flattened" that the build, the API and the suite all read, and it fails a deck
when any of three rules breaks:
- no single picture covers the slide - not
>= 95%of the slide width and>= 95%of its height; - all pictures together cover at most 80% of the slide (the union of their boxes, so overlap counts once) - four 50%x50% pictures pass rule 1 while tiling exactly the screenshot rule 1 forbids;
- a picture-only slide is a flattened slide - a slide carrying a picture must also carry at least one native shape, connector, table or text box.
When the analyzer's own output would break a rule, the offending image
element is skipped with a warning naming it; a flattened deck is never written.
browser (React) ──┐
├──▶ FastAPI ──▶ Vision LLM ──▶ SlideSpec (JSON) ──▶ python-pptx ──▶ .pptx
backend/cli.py ───┘ (gemini-flash validated native shapes,
via LiteLLM) by pydantic tables, text
The web app and the CLI are two front doors onto the same conversion core
(backend/app/). Nothing about the validation, the coordinate system or the
builder changed when the UI was added.
The browser UI is in Korean and shows a preview of the
slide before you download it. The API and the CLI
stay English: codes like invalid_spec are a contract, and the UI translates
them on the way to the screen (frontend/src/api/client.ts).
- Layout
- Quick start
- Running it by hand
- Environment variables
- Prerequisite: a LiteLLM proxy
- Preview before download
- API reference
- The 0..1000 coordinate system
- The JSON spec format
- The CLI
- Tests
- Troubleshooting
image-to-ppt/
├── run.sh one command to bring the whole stack up
├── README.md this file
├── .env.example placeholder env (the real key stays in your shell)
├── .gitignore
├── .venv/ python virtualenv (created by run.sh)
│
├── backend/
│ ├── app/
│ │ ├── config.py endpoint, model, slide geometry, styling defaults
│ │ ├── schemas.py the pydantic contract + tolerant validation
│ │ ├── vision_analyzer.py prompt, request, JSON recovery
│ │ ├── pptx_builder.py SlideSpec ➜ .pptx (native objects + fenced crops)
│ │ ├── mock_data.py a hand-built sample spec - a TEST FIXTURE only,
│ │ │ not a product feature (Mock mode is gone)
│ │ ├── main.py the FastAPI app: CORS, router, one error envelope
│ │ ├── api/routes.py the /api routes and nothing else of substance
│ │ └── services/
│ │ ├── converter.py framework-free orchestration, shared with the CLI
│ │ └── preview.py walks the BUILT deck -> the render-ready preview
│ ├── cli.py the original CLI, preserved
│ ├── make_sample_image.py draws a synthetic 1600x900 test slide with Pillow
│ ├── sample.png a ready-made input image
│ ├── requirements.txt
│ ├── pytest.ini
│ └── tests/ test_schemas / test_pptx_builder / test_vision_analyzer
│ / test_preview / test_cli / test_config / test_api
│ / test_env_file / test_frontend_guards - fully offline
│
└── frontend/
├── index.html
├── package.json
├── vite.config.ts dev server on 5173, /api proxied to 8000
├── tailwind.config.js
├── postcss.config.js
├── tsconfig.json
└── src/
├── main.tsx App.tsx
├── api/client.ts one typed wrapper per endpoint
├── components/ FileUploader, OptionPanel, SlidePreview,
│ ResultView, SpecEditor
└── types/slide.ts the SlideSpec + preview types, mirrored from
schemas.py and services/preview.py
The five core modules - config.py, schemas.py, vision_analyzer.py,
pptx_builder.py, mock_data.py - were moved, not rewritten, when the
project grew a web tier. They import each other relatively (from . import config), so the same code backs the CLI, the HTTP API and the tests with no
sys.path juggling.
Needs Python 3.10+ and Node 18+ on PATH. Everything else is installed
for you.
git clone https://github.com/sort-tech/image-to-ppt
cd image-to-ppt
export LITELLM_API_KEY='<your proxy key>' # or put it in .env (git-ignored)
./run.shThat single command:
- creates (or reuses) the virtualenv at
.venv/and installsbackend/requirements.txt- skipping the install, and saying so, when nothing changed; - runs
npm installinfrontend/only whennode_modulesis missing orpackage.jsonis newer; - starts uvicorn (
app.main:app) on :8000 with--reloadand the Vite dev server on :5173, both in the background, each log line tagged[api]or[web]; - waits until both answer, then prints the URLs;
- on Ctrl-C, kills both children and their descendants - uvicorn's
reload worker and Vite's
nodechild included - then confirms the ports are free. No orphan is ever left holding :8000 or :5173.
Open http://localhost:5173/. The browser only ever talks to :5173; Vite
proxies /api through to the backend, so requests are same-origin.
./run.sh # both services (same as ./run.sh all)
./run.sh backend # just uvicorn on :8000
./run.sh frontend # just Vite on :5173
./run.sh test # pytest (backend) + tsc --noEmit (frontend)
./run.sh help # usageIt resolves its own directory, so /path/to/image-to-ppt/run.sh works
from anywhere. It refuses to start - naming the port, the PID and the kill
command - if :8000 or :5173 is already taken, and it warns (but still starts)
when LITELLM_API_KEY is unset, because the spec-driven endpoints
(/api/preview, /api/build-from-json) work without one.
Both ports can be moved when something else owns the defaults:
WEB_PORT=5174 ./run.sh frontend # safe: nothing points at 5173 but you
API_PORT=8010 ./run.sh backend # also update the proxy target in
# frontend/vite.config.ts to matchUseful when you want the two halves in separate terminals, or a production-ish frontend build.
Backend (terminal 1):
cd image-to-ppt
python3 -m venv .venv # first time only
.venv/bin/python -m pip install -r backend/requirements.txt
export LITELLM_API_KEY='<your proxy key>' # skip for /api/preview + /api/build-from-json
cd backend
../.venv/bin/python -m uvicorn app.main:app --port 8000 --reloadCheck it:
curl -s http://127.0.0.1:8000/api/healthFastAPI also serves interactive docs at http://127.0.0.1:8000/docs.
Frontend (terminal 2):
cd image-to-ppt/frontend
npm install # first time, or after package.json changes
npm run dev # http://localhost:5173/Production build of the frontend (static files, then serve them however you like):
cd image-to-ppt/frontend
npm run build # type-checks, then writes dist/
npm run preview # serves dist/ on :5173npm run preview has no /api proxy. The client defaults to the relative base
/api, so for a previewed or separately hosted build, point it straight at the
backend at build time - including the /api path:
VITE_API_BASE=http://localhost:8000/api npm run build
npm run preview # http://localhost:5173/Those calls are cross-origin, which is exactly what the backend's CORS
allow-list (http://localhost:5173, http://localhost:3000) exists for.
| variable | default | meaning |
|---|---|---|
LITELLM_BASE_URL |
http://localhost:4000/ |
base URL of the OpenAI-compatible proxy. Needs the scheme |
LITELLM_API_KEY |
(empty - no default) | key the proxy expects. Required for a real conversion; any value your proxy accepts will do, including a dummy one for a local proxy that does not check |
VISION_MODEL |
gemini-flash |
model / alias to request. Overridable per request (model field) or per run (-m) |
A variable that is present but blank (VISION_MODEL= on its own line in
.env) counts as unset and falls back to the default above. It used to be
copied through as "", and nothing downstream could recover it: the proxy
answered Invalid model name passed in model= and /api/health reported no
model at all, which the UI's status pill - the only place the model is named
since the picker went - rendered as a dangling · 키 설정됨.
There are two ways to set them. A .env file in the repository root is the
easy one - it is git-ignored and loaded automatically:
cp .env.example .env # then edit .env and put your key in
./run.shOr export them in the shell you start the stack from:
export LITELLM_BASE_URL='http://localhost:4000/'
export LITELLM_API_KEY='<your proxy key>'
export VISION_MODEL='gemini-flash'
./run.sh # start the stack in the SAME shellPrecedence: the real environment always wins over .env, so you can
override one value for a single run without editing the file:
LITELLM_API_KEY='sk-other' ./run.sh.env is read by run.sh, by uvicorn app.main:app and by backend/cli.py -
each of them loads it before anything reads the settings, so it works however
you start the app. backend/app/config.py itself only reads os.environ, on
purpose: the test suite never sees your .env, so a local file cannot
change a test result. The loader lives in backend/app/env_file.py; it accepts
comments, blank lines, an export prefix, quoted values and CRLF, skips a
malformed line instead of giving up on the file, and logs only variable names.
The backend says which of the two it used at startup, so a file that was never loaded is obvious:
[api] INFO loaded /path/to/.env -> set LITELLM_API_KEY, LITELLM_BASE_URL
The key never lives in the source. There is no default in
backend/app/config.py, and a test (backend/tests/test_config.py) scans the
entire tree - python and frontend sources - and fails the build if a key
literal reappears anywhere. .env is excluded from that scan, because a
git-ignored .env is exactly where a real key belongs; .env.example is
scanned and holds placeholders only.
GET /api/health reports whether a key is configured as a plain boolean. It
never echoes the key, or any prefix of it.
Neither does anything else. A proxy that rejects the key answers with
Received API Key = sk-...<last 4>, Key Hash (Token) =<sha256 of the whole key>, and the OpenAI SDK puts that body verbatim into the exception text, so
every string on its way to a client - the analysis_failed hint and the
warnings array alike - goes through converter.scrub_secrets first: anything
shaped like a key, a hex digest, or a labelled credential is replaced with
[redacted], and the configured key's own value is removed literally. The
unredacted upstream error is written to the server log instead. Set
I2P_DEBUG_UPSTREAM_ERRORS=1 on the server to keep the raw text in responses
while debugging the proxy.
With the variable unset or blank, converting an image fails immediately with
503 missing_api_key (the hint names the variable and the export) - it never
sends a keyless request. POST /api/preview, POST /api/build-from-json,
GET /api/health, --from-json and the whole test suite need no key at all,
because they build from a spec instead of calling the model.
Other knobs are plain constants in backend/app/config.py: LLM_TIMEOUT
(120s), LLM_MAX_RETRIES (2), SLIDE_WIDTH_IN / SLIDE_HEIGHT_IN
(13.333 x 7.5in, i.e. 16:9), COORD_SCALE (1000), DEFAULT_TEXT_FONT_SIZE
(12pt), DEFAULT_TABLE_FONT_SIZE (10pt), DEFAULT_BORDER_WIDTH_PT (1.0).
Upload limits live in backend/app/services/converter.py:
MAX_UPLOAD_BYTES (20 MB), DOWNLOAD_TTL_SECONDS (30 min),
DOWNLOAD_MAX_ENTRIES (64).
The tool talks to an OpenAI-compatible /chat/completions endpoint. It does
not call Google directly - it expects a LiteLLM
proxy in front of the vision model, so the same code works with any provider
you can route through it.
# in another terminal
pip install "litellm[proxy]"
export GEMINI_API_KEY=... # whatever your upstream provider needs
cat > litellm.config.yaml <<'YAML'
model_list:
- model_name: gemini-flash # the name this tool asks for
litellm_params:
model: gemini/gemini-2.0-flash # the upstream model it maps to
api_key: os.environ/GEMINI_API_KEY
YAML
litellm --config litellm.config.yaml --port 4000The model_name on the left is what you put in VISION_MODEL; the
litellm_params.model on the right is the real upstream model, so you can
repoint or swap providers without touching this code. Any vision-capable model
works - gemini-flash is just the default.
The proxy must be reachable before an image can be converted.
POST /api/preview and POST /api/build-from-json do not need it at all.
Every conversion comes back with a preview: a render-ready description of
the slide, which the UI draws as an SVG so you see the deck before you decide
to download it. POST /api/preview re-derives it from an edited spec, so the
loop is convert → look → edit the spec → preview → download.
The preview is read out of the Presentation object that was actually
built - services/preview.py walks slide.shapes and reports each shape's
real geometry and styling. It never looks at the SlideSpec. That is the whole
point: re-deriving it from the spec would be a second implementation of
pptx_builder.scale_bbox, and the moment the two disagreed you would be looking
at a picture of a slide that does not exist. Reading the built deck means the
preview shows, for free:
| shown by construction | because |
|---|---|
| the min-dimension floor | a 5-unit-high band is previewed at the 0.05in it was enlarged to, not at the 0.0375in the spec asked for |
| off-slide clipping | a bbox that overshot 1000 is previewed where it really landed |
| cleaned text | control characters dropped, CRLF split into paragraphs, lone surrogates replaced - the runs are read back out of the deck |
| z-order | slide.shapes is in document order, which is the z-order, back to front |
Geometry is emitted as percentages of the slide (so the UI is trivially
responsive) and font sizes in points (the slide is 960 x 540pt, so an SVG
with viewBox="0 0 960 540" maps one point to one user unit). Percentages are
deliberately not clamped - a shape may legitimately sit partly off-slide,
and clipping it is the renderer's job.
Deriving the preview can never fail a conversion: any error leaves
"preview": null and adds a line to warnings, and the deck is still built and
still downloadable.
# see what a spec really builds, without storing anything
curl -s -H 'Content-Type: application/json' \
--data '{"elements":[{"type":"shape","shape":"rectangle","bbox":{"x":100,"y":100,"w":500,"h":5}}]}' \
http://127.0.0.1:8000/api/preview
# -> height_pct 0.6667 (= 0.05in, the FLOOR) with the matching warning,
# not the 0.5 the spec's h=5 would suggestMock mode has been removed from the whole product - the UI toggle, the
is_mockAPI field and the CLI's--mockflag are all gone.backend/app/mock_data.pystays as the test suite's shared fixture (sample_spec()); it is not reachable from any user-facing code path.POST /api/previewandPOST /api/build-from-jsonare what now cover "prove the builder and the UI work without a live model".
Base path /api. All JSON is snake_case. CORS allows
http://localhost:5173 and http://localhost:3000 (all methods, all headers).
Every error uses the same envelope, so one client-side handler covers all
of them - and every status carries the CORS headers, including 500: the
envelope is produced by ErrorEnvelopeMiddleware inside the CORS layer,
because starlette's own Exception handler runs outside it and its response
would reach the browser without Access-Control-Allow-Origin (i.e. as an
unreadable network error, exactly when the hint matters most):
{ "detail": { "code": "unsupported_extension", "message": "...", "hint": "..." } }Configuration only. This endpoint never fails because the proxy is down - it reports what is configured, not what is reachable.
{
"status": "ok",
"proxy": {
"base_url": "http://localhost:4000/",
"model": "gemini-flash",
"api_key_configured": true
},
"supported_extensions": [".jpeg", ".jpg", ".png", ".webp"],
"slide": { "width_in": 13.333, "height_in": 7.5, "coord_scale": 1000 }
}api_key_configured is a bool. It never echoes the key or any prefix of it.
curl -s http://127.0.0.1:8000/api/health| field | type | required | meaning |
|---|---|---|---|
file |
file | yes | the image (.png, .jpg, .jpeg, .webp), max 20 MB |
model |
string | no | overrides VISION_MODEL for this request |
extra_instructions |
string | no | appended to the user prompt sent to the model |
There is no is_mock field. An extra form field is ignored, so a stale client
that still posts is_mock=true gets an ordinary conversion - and still a
503 missing_api_key without a key.
{
"status": "success",
"counts": { "shape": 4, "table": 1, "text": 9 },
"total": 14,
"dropped": ["element 1 (table): data - Field required"],
"warnings": ["bbox (505, 601, 461, 6): height is below the 0.05in minimum; enlarged it so the element stays visible. ..."],
"spec": { "background_color": "#FFFFFF", "elements": [] },
"preview": { "slide": {}, "background_color": "#FFFFFF", "shapes": [] },
"download_id": "0f3c9a1b2d4e4f6a8b0c1d2e3f405162",
"filename": "sample.pptx"
}(Illustrative: both dropped and warnings are shown non-empty so you can see
their shape. Converting backend/sample.png for real gives
counts: {"shape": 4, "table": 1, "text": 9}, total: 14, dropped: [] and
one warning about the 6-unit-high band.)
dropped and warnings are always arrays - empty when nothing happened.
spec is the validated SlideSpec as JSON and never contains a dropped
key (diagnostics are output-only, so they cannot be forged; see
the spec format). preview is the render-ready
description of the built deck, or null with a line in warnings if deriving
it failed - see POST /api/preview for
its shape. Fetch the deck itself with the download_id.
curl -s -F 'file=@backend/sample.png' http://127.0.0.1:8000/api/convertErrors:
| status | code |
when |
|---|---|---|
| 400 | unsupported_extension |
extension not in the supported list (the message names the list). A name with no extension at all - including a dotfile name like .png, which POSIX reads as a name and not a suffix - is reported as '<no extension>', never quoted back as if it were one |
| 400 | empty_file |
zero bytes |
| 400 | not_an_image |
the bytes are not a decodable image |
| 413 | file_too_large |
over the 20 MB cap |
| 503 | missing_api_key |
LITELLM_API_KEY unset/blank. The hint names the variable, the export, and the fact that /api/preview and /api/build-from-json need no key |
| 502 | proxy_unreachable |
the proxy could not be reached (the hint names LITELLM_BASE_URL) |
| 502 | analysis_failed |
the model replied but the reply could not be used |
| 500 | build_failed |
the deck could not be built - and the code for any other unexpected failure of the pipeline, so this table is the whole error surface |
Completes a rough note into ONE instruction written for the slide analyzer, in
the note's own language, ready to paste into extra_instructions on the next
/api/convert.
The flow it exists for: convert, look at the preview, spot something wrong, type "표가 이상해", and get back an instruction that actually names the element. That is why context matters - with only the bare note there is nothing to do but tidy the grammar.
| field | type | required | meaning |
|---|---|---|---|
note |
string | yes | the rough text, in any language |
spec |
string | no | the current SlideSpec as JSON, when a conversion has already happened. Parsed leniently: an unusable value is ignored, not rejected - it is only context |
file |
file | no | the source image, used when there is no spec yet |
model |
string | no | overrides VISION_MODEL for this request |
Context is picked cheapest-first and reported rather than assumed: spec
when it parses and has elements (text-only, no vision call), otherwise file
(one vision call), otherwise neither. context_used says which it really was,
so the UI can name the context instead of implying the best case.
The spec is summarized, never dumped - a 14-element spec as raw JSON is
thousands of tokens of bbox integers the model cannot use to work out which
element the note means. What goes up is a capped digest of
type - rough position in words - rough extent in words - short excerpt, with
no coordinates and no sizes in figures at all (for backend/sample.png: under a
thousand characters against 2,614 of raw JSON).
The extent is words for the same reason the position is: a digest that carried
460x5 got the numbers quoted straight back at the analyzer, which has its own
idea of the 0..1000 grid. But it has to be there - without it a 460x5 divider
and a full-slide card produced identical lines, so 구분선이 너무 두꺼워 had
nothing to attach to and mis-targeted the header band in 5 of 8 sampled runs.
얇은 가로선, 가로로 넓고 낮은 띠, 큰 영역, 작은 영역 and the rest carry
that much and cost a handful of characters.
An unusable spec is ignored rather than rejected, and that holds for one that
parses and then cannot be encoded: a lone surrogate ("\ud800") survives
json.loads but no UTF-8 encoder, and it used to fail inside the HTTP client
rather than here. Strings that reach the digest are sanitized where they are
built, so the assist still works for that caller.
{
"status": "success",
"instruction": "우측 중앙 영역은 3열 3행의 단일 표로 인식하세요. 첫 행(Region | Users | Growth)을 헤더로 지정하고, 셀 내부의 텍스트를 개별 텍스트 상자로 분리하지 마세요.",
"note": "표가 이상해",
"context_used": "spec",
"fell_back": false
}instruction is never empty, by construction: the UI writes it straight
into the box the user typed in, so an empty value would delete their own words.
If the model returns nothing usable the tidied note comes back instead,
context_used is downgraded to "none" - claiming the slide informed an answer
that is only the user's own sentence would be a lie - and fell_back is true.
fell_back is there because context_used alone cannot tell two different
events apart. Both answer "none": a genuine no-context run, where the model
really does write an instruction from the note alone, and this fallback, where a
digest was built and sent and nothing came back. They need different sentences
on screen, and the UI prints one of them - so "there was no analysis or image"
must not be said about a request that carried one.
curl -s -F 'note=표가 이상해' -F 'spec=<spec.json' \
http://127.0.0.1:8000/api/refine-instructionsErrors:
| status | code |
when |
|---|---|---|
| 400 | empty_note |
note missing or blank after trimming. Checked before the optional file, so a caller who forgot the note gets the code that names what they forgot |
| 400 | empty_file / not_an_image |
a file part with a filename but no bytes, or bytes Pillow cannot decode |
| 413 | file_too_large |
over the same 20 MB cap as /api/convert |
| 503 | missing_api_key |
LITELLM_API_KEY unset/blank |
| 502 | proxy_unreachable |
the proxy could not be reached |
| 502 | refine_failed |
the model was reached but gave nothing usable |
There is no build_failed here: nothing is built. A malformed spec is not an
error at all - it degrades to the next context down and says so.
What a spec really builds, without storing anything: the deck is built so the geometry reported is python-pptx's own (floors and clipping included), then the bytes are thrown away. No model call, no key, no network. This is what the UI calls to refresh the slide after you edit a spec.
The body is read exactly as /api/build-from-json reads it - a bare
SlideSpec, a wrapper {"spec": {...}} or a bare element array all work, via
the same normalize_spec_payload.
The response is the POST /api/convert response without download_id and
filename, because nothing is kept:
{
"status": "success",
"counts": { "shape": 4, "table": 1, "text": 9 },
"total": 14,
"dropped": [],
"warnings": [],
"spec": { "background_color": "#FFFFFF", "elements": [] },
"preview": {
"slide": { "width_in": 13.333, "height_in": 7.5, "width_pt": 959.98, "height_pt": 540.0 },
"background_color": "#FFFFFF",
"shapes": [
{ "kind": "shape", "autoshape": "rectangle",
"left_pct": 0.0, "top_pct": 0.0, "width_pct": 100.0, "height_pct": 15.6,
"fill_color": "#1D356A", "line_color": null, "line_width_pt": null,
"line_dash": "solid" },
{ "kind": "connector",
"start_pct": { "x": 12.5, "y": 30.0 },
"end_pct": { "x": 48.0, "y": 30.0 },
"elbow": false, "arrow_start": false, "arrow_end": true,
"dash": "solid", "color": "#1E3A6E", "width_pt": 1.5 },
{ "kind": "image",
"left_pct": 60.0, "top_pct": 20.0, "width_pct": 30.0, "height_pct": 40.0,
"data_uri": "data:image/png;base64,...", "description": "team photo" },
{ "kind": "text",
"left_pct": 2.8, "top_pct": 5.0, "width_pct": 38.0, "height_pct": 5.0,
"align": "left",
"paragraphs": [
{ "text": "Quarterly Platform Review", "font_size_pt": 32.0,
"font_color": "#FFFFFF", "bold": false, "align": "left" }
] },
{ "kind": "table",
"left_pct": 50.5, "top_pct": 20.4, "width_pct": 46.0, "height_pct": 32.5,
"col_widths_pct": [33.3333, 33.3333, 33.3333],
"row_heights_pct": [33.3333, 33.3333, 33.3333],
"rows": [
[ { "text": "Region", "fill_color": "#1D356A", "font_size_pt": 10.0,
"font_color": "#FFFFFF", "bold": true, "align": "center" } ]
] }
]
}
}Rules the payload keeps:
shapesis already in z-order, back to front - it isslide.shapesin document order, and sorting it would be the bug.- every numeric field is a JSON number, never a string;
left_pct/width_pctare percentages of the slide's width,top_pct/height_pctof its height, and they are not clamped. col_widths_pct/row_heights_pctare percentages of the table's own box, each summing to ~100, so the UI's grid fills its frame exactly.- a
nullcolour means no fill / no outline / inherited - see-through, never a guess and never black. Only an explicit RGB is reported. font_size_ptfalls back to the builder's own default (DEFAULT_TEXT_FONT_SIZE12pt /DEFAULT_TABLE_FONT_SIZE10pt) when a run carries no explicit size, so the UI never has to guess.- a connector carries
start_pct/end_pctand noleft_pct/width_pctbox, because it has nobboxin the spec either; a box would also lose which end the arrowhead is on.dash,arrow_start,arrow_end,colorandwidth_ptare read back off the deck'sa:ln, andelbowsays the deck usedbentConnector3. - an
image'sdata_uriis the crop that is really embedded in the deck (shape.image.blob), never the source image: downscaled to a longest side of 480px and capped at 96KB, andnull(with awarningsline) when it cannot be read or will not fit. Anullis drawn as a labelled placeholder, not as an empty box. - a shape kind the payload has no reader for comes back as
{"kind": "unsupported"}rather than crashing. Pictures and connectors are not in that bucket any more - they have their own entries above.
curl -s -H 'Content-Type: application/json' \
--data '{"elements":[{"type":"text","bbox":[100,100,500,100],"content":"hello"}]}' \
http://127.0.0.1:8000/api/previewErrors: 400 invalid_spec and 500 build_failed, the same envelope and
the same two codes as /api/build-from-json.
Rebuild a deck from a spec you already have - no model call, no key, no network. Free, and the way to iterate on geometry by hand. Unchanged by the preview work: it still answers with the binary.
The body may be a bare SlideSpec, a wrapper {"spec": {...}}, or a bare
element array; normalize_spec_payload recovers all three. Optional
query parameter filename (default slide.pptx).
Response headers:
| header | value |
|---|---|
Content-Type |
application/vnd.openxmlformats-officedocument.presentationml.presentation |
Content-Disposition |
attachment; filename="<ascii-name>.pptx", plus ; filename*=UTF-8''<percent-encoded> when the name is not ASCII |
X-Element-Counts |
the counts object as a compact JSON string, e.g. {"shape":0,"table":0,"text":1} |
X-Dropped-Count |
the number of dropped elements, as a string |
X-Element-Counts exists so the UI can report what it got without a second
round trip. Both custom headers - and Content-Disposition - are listed in the
CORS expose_headers, so browser JS can read them cross-origin as well as
through the proxy.
filename= is latin-1 only, and the sanitizer that makes a name safe to
interpolate there removes non-ASCII rather than escaping it - so on its own
it turned 분기보고서.png into slide.pptx and 매출표_Q3.png into Q3.pptx.
RFC 5987's filename* carries the user's own characters (UTF-8,
percent-encoded) and is what browsers prefer, so the download keeps its name;
a client that only understands filename= still gets the valid ASCII one. It
is emitted only when it would say something filename= cannot, so an
ASCII upload's header is exactly one parameter, as before.
The JSON filename field of POST /api/convert is the same display name
(the one the 변환 완료 panel shows and the browser falls back to), not the
latin-1 one: it travels as UTF-8 JSON and ends up in an <a download>, so it
has no latin-1 constraint, and having it disagree with the file the user
actually saves would be its own bug. Path parts, quotes, control characters
and the punctuation Windows forbids are still removed from it;
pptx_headers is the only place the ASCII-only form is produced, and it
derives both parameters itself rather than trusting a caller.
curl -s -D /tmp/h.txt -o /tmp/rebuilt.pptx \
-H 'Content-Type: application/json' \
--data '{"elements":[{"type":"text","bbox":[100,100,500,100],"content":"hello"}]}' \
'http://127.0.0.1:8000/api/build-from-json?filename=rebuilt.pptx'
grep -i 'x-element-counts\|x-dropped-count' /tmp/h.txtErrors: 400 invalid_spec (when elements were dropped, the hint lists the
per-element reasons) and 500 build_failed - those two and nothing else.
The body is capped at 2 MB (converter.MAX_JSON_BODY_BYTES), counted as it
arrives and refused as invalid_spec: a spec is kilobytes, while decoding a
body costs several times its size in memory. A body that is malformed in any
way - unparseable, empty, or nested too deeply for the parser (which raises
RecursionError, not ValueError) - is that same 400, never a 500.
The deck produced by a POST /api/convert, with the same four headers as
above.
curl -s -o /tmp/out.pptx http://127.0.0.1:8000/api/download/<download_id>Downloads live in an in-process store with a 30-minute TTL and a cap of
64 entries (oldest evicted first). A download does not consume the
entry, so re-downloading the same id works for as long as it lives. Restarting
the backend - including an automatic --reload restart - empties the store.
404 unknown_download when the id is unknown or expired; the hint tells you
to re-run the conversion. file_id is validated as a uuid4 hex string before
any lookup and is never interpolated into a filesystem path - a malformed id
such as .. is refused by the pattern, so it never reaches the store.
| route | what it is |
|---|---|
GET /docs, GET /openapi.json |
FastAPI's interactive docs and schema |
GET / |
a small JSON pointer to /docs, /api/health and the endpoint list, so the bare root is not a 404 |
And a few extra statuses you can hit from a client bug rather than a bad image:
422 invalid_request when the request itself cannot be read - POST /api/convert with no file part, say, where the hint is
file: Field required. It uses the same envelope as everything else, because
FastAPI's own {"detail": [ ... ]} list would otherwise be the one error shape
a client had to special-case.
The same envelope also wraps the failures starlette raises before any route of
ours runs (main._CODE_BY_STATUS): 404 not_found for a wrong URL - a
VITE_API_BASE missing its /api, typically - 405
method_not_allowed for a wrong verb, and 400 bad_request for a body
the multipart parser cannot read. Their message is starlette's own English
string and their hint is empty, so the UI supplies all the copy for them;
tests/test_frontend_guards.py asserts every code in that map has Korean copy
in frontend/src/api/client.ts.
Every bbox is [x, y, w, h] in integers from 0 to 1000, where x/w are
relative to the image width and y/h to the image height.
[0, 0, 1000, 1000] is the whole slide; [500, 0, 500, 1000] is the right
half. All four components are required.
pptx_builder.scale_bbox maps that onto the 16:9 canvas:
left = Inches(bbox.x / 1000 * 13.333)
top = Inches(bbox.y / 1000 * 7.5)
width = Inches(bbox.w / 1000 * 13.333)
height = Inches(bbox.h / 1000 * 7.5)Why integers instead of 0.0-1.0 floats?
- Resolution independence. The model never sees pixel dimensions, so the same spec renders identically whether the input was 800x450 or 4K.
- LLMs are bad at floats. Models emit
0.3333333333333333,.33,33%,"0,33"and occasionally1.0000001. Integers on a familiar 0-1000 ruler are a token-cheap, low-ambiguity target, and per-mille granularity is ~13 thousandths of an inch on a 13.3in slide - far finer than anyone can see. - Cheap, safe validation. Clamping to
0..1000and flooringw/hat 1 is trivial and total: no NaN, no infinity, no negative-size shapes, no silently off-slide geometry. - Hand-editable. A spec full of round numbers is pleasant to fix by hand; a dump full of 17-digit floats is not.
Element order is z-order: earlier elements are painted first, later ones on top. The prompt tells the model to emit background and container shapes early and tables/text late, and the builder preserves that order exactly.
Slides are always 13.333 x 7.5 in with sldSz type="screen16x9".
Tables default to 10pt (DEFAULT_TABLE_FONT_SIZE), smaller than the 12pt
text default. This is not a style preference, it is a python-pptx constraint:
PowerPoint auto-grows a table row to fit its content and python-pptx cannot
shrink a row below the height its text needs. Ask for 18pt in a table you
placed 2 inches tall and PowerPoint will push it off the bottom of the slide,
wrecking the reproduction. So cells are rendered compactly. If your source
table is genuinely large-print, set font_size on the table element and rebuild
via POST /api/build-from-json or --from-json.
Element types and fields:
shape-kindisrectangle(default),rounded_rectangle,circleor one of the block arrowsarrow_right/arrow_left/arrow_up/arrow_down;fill_color,border_color,border_width. A shape with nofill_coloris drawn unfilled, so it can frame content painted later. Shapes never carry text.table-datais a 2-D array of strings; ragged rows are padded with"";header_bg_color,header_font_bold,font_size.text-content(required, with\nfor line breaks),font_size,font_color,bold,align.connector- a diagram connector, the one element type with nobbox: it is defined bystart_pointandend_point(each a{"x":…,"y":…}dict or an[x, y]pair, on the same 0..1000 grid, clamped the same way).kindisstraight(default) orelbow;arrow_start(defaultfalse) andarrow_end(defaulttrue) say which end carries the arrowhead;dash_styleissolid(default) ordashed;colorandwidth_ptfollow the usual colour/size rules. A missing or unreadablex/yinvalidates the element exactly as a brokenbboxdoes -connector 3: start_point - 'y' is missing or unreadable.image- a region cropped out of the source image and embedded as a picture:bboxplus an optionaldescription(written into the picture's alt text, and read back from there by the preview). This is the flattening exception fenced by the three rules at the top of this file, and it is meant for a photo, a rendered chart, a logo or a complex illustration - not for ordinary boxes, text or tables, and never for one big region covering the slide. Only a conversion has the source image:POST /api/preview,POST /api/build-from-jsonandcli.py --from-jsonhave nothing to crop from, so they skip the element, warn that the conversion has to be re-run, and draw a dashed grey placeholder outline at the bbox so the gap is visible in the deck. An EXIF-rotated source (an ordinary portrait phone photo) is normalized to its displayed orientation before the crop, which is the frame the bbox is measured in.
Robustness rules baked into the schema: colours accept #FFF, fff,
FFFFFF, #ffffff and #RRGGBBAA (alpha dropped) and fall back to None if
unparseable; bbox accepts a list, a tuple or a dict (including x1/y1
corner keys) and coerces strings and floats before clamping; unknown extra keys
are ignored.
Tolerance stops at absent, though: all four of x/y/w/h must be
present and parseable. A missing or unreadable component makes that element
invalid - element 0 (text): bbox - 'h' is missing or unreadable - because
defaulting it would render an invisible 0.05in sliver and report success over a
deck the user cannot read.
The same rule covers a text element's content: absent gives
element 0 (text): content - Field required, null gives
element 0 (text): content - must not be null .... "content": "" stays valid
on purpose - an empty text box you can click and type into is a real, editable
object.
Text that PowerPoint cannot store at all - a lone UTF-16 surrogate, the kind
json.loads('"\ud800"') produces - is replaced with U+FFFD so the rest of the
string still renders, and the substitution is reported truthfully (on stderr
for the CLI, in warnings for the API). The element is rendered, not skipped.
Elements are validated one at a time: an element that cannot be repaired (a
table with no data, an unknown type, an incomplete bbox) is dropped with
a reason and the rest of the slide is still built, so the run still succeeds.
That holds for a bare [element, ...] array too: one unrecognized entry costs
only that entry. The reasons surface as dropped in the API response and on
stderr in the CLI; on the model they live on SlideSpec.dropped, which is
output-only - a dropped key in the input is stripped, so neither a spec
file nor the model can fabricate a diagnostic, and it is excluded from
serialization so a saved spec stays clean.
Losing every element is a hard failure instead (400 invalid_spec /
ElementsAllDroppedError): a silently blank deck is worse than an error.
A box that has to be moved, trimmed or enlarged to render also warns, so the geometry you see is never quietly different from the geometry the spec asked for.
Live copies of this example live in the test fixture
backend/app/mock_data.py: SAMPLE_SPEC_JSON (string) and sample_spec()
(validated object). That module is a fixture the suite shares, not a
product feature - nothing a user can reach imports it.
backend/cli.py is the original command-line tool, preserved unchanged in
behaviour. It shares the conversion core with the API, so a spec produced by
one is buildable by the other.
cd image-to-ppt/backend
../.venv/bin/python cli.py --input sample.png --output result.pptx| flag | description |
|---|---|
-i, --input PATH |
image to analyze. Required unless --from-json. Supported: .png, .jpg, .jpeg, .webp |
-o, --output PATH |
output .pptx. Defaults to the input stem (shot.png ➜ shot.pptx), or out.pptx |
-m, --model NAME |
override VISION_MODEL / DEFAULT_MODEL for this run |
--from-json PATH |
skip the LLM entirely and build from a saved spec |
--save-json PATH |
also write the validated spec to PATH as JSON, for inspection or reuse with --from-json |
--extra TEXT |
extra instruction appended to the user prompt |
-v, --verbose |
tracebacks and payload diagnostics |
Exit codes: 0 success, 1 runtime / LLM / build failure, 2 bad usage or unsupported input.
All of these run from backend/; PY=../.venv/bin/python.
cd image-to-ppt/backend
PY=../.venv/bin/python
# 0. make yourself an input image (no assets required)
$PY make_sample_image.py # writes ./sample.png
# 1. the happy path
$PY cli.py -i sample.png -o /tmp/result.pptx
# 2. keep the spec so you can tweak it by hand and rebuild for free
$PY cli.py -i sample.png -o /tmp/slide.pptx --save-json /tmp/slide.json
$EDITOR /tmp/slide.json
$PY cli.py --from-json /tmp/slide.json -o /tmp/slide_v2.pptx
# 3. nudge the model when it misreads something
$PY cli.py -i sample.png --extra "The right column is a 4-row table, not text."
# 4. try a different model through the same proxy (any name it serves - see
# "Unknown model" under Troubleshooting for how to list them)
$PY cli.py -i sample.png -m <another-model-your-proxy-serves> -o /tmp/other.pptx
# 5. no proxy, no key, no network: prove the builder works from a saved spec.
# `--from-json` is the one CLI path that calls no model, so it needs a spec
# file that already exists: either /tmp/slide.json from example 2, or - with
# no key at all, ever - seed one from the suite's spec fixture first.
# (app/mock_data.py is a TEST fixture, not a product feature; printing it is
# just the handiest keyless source of a valid spec.)
$PY -c "from app import mock_data; print(mock_data.SAMPLE_SPEC_JSON)" > /tmp/spec.json
$PY cli.py --from-json /tmp/spec.json -o /tmp/demo.pptx
# 6. diagnose a bad run
$PY cli.py -i sample.png -v -o /tmp/verbose.pptx./run.sh test # pytest + tsc --noEmitor each half on its own:
cd image-to-ppt/backend && ../.venv/bin/python -m pytest -q
cd image-to-ppt/frontend && npm run typecheckbackend/pytest.ini pins testpaths = tests and pythonpath = ., so a bare
pytest -q from backend/ works too and from app import ... resolves
without any sys.path manipulation.
The python suite is offline by design - no socket is ever opened. LLM calls
go through a fake client whose .chat.completions.create(...) returns an object
shaped like an OpenAI response, which also lets the tests assert on the exact
request that was built.
Coverage highlights:
tests/test_schemas.py- colour normalization (including garbage ➜None),BBoxcoercion and clamping, ragged-row padding, discriminated-union dispatch, extra keys ignored, per-element drop isolation, thedroppedstrip-on-input rule, and the sample spec round-tripping through JSON.tests/test_vision_analyzer.py-encode_imagepayloads and MIME types,extract_jsonagainst fenced, prose-wrapped, deeply nested, braces-inside-strings and metadata-prefixed responses, the vision message payload, andanalyze_imagehappy path / invalid JSON / transport failure / theresponse_format-rejected retry.tests/test_pptx_builder.py- slide geometry, per-element shape mapping, z-order preservation, table sizing and fonts, textbox wrapping and alignment, connector endpoints / arrowheads (written as rawa:headEnd/a:tailEnd, since python-pptx has no arrowhead API) / dash styles, cropping animageelement out of the source, graceful degradation of bad colours, one broken element not aborting the build, and the reopened deck passingpptx_builder.flattening_report()- the three anti-flattening rules quoted at the top of this file, plus the check that every embedded picture's bytes really resolve inside the package.tests/test_cli.py---from-json,--save-json,--mockbeing gone, flag forwarding, exit codes (0/1/2), friendly errors without tracebacks, the Pillow sample-image generator, and a scan of this README's own bash blocks asserting that no command sitting under a "no proxy / no key / no network" promise is a-ivision conversion (which needs both).tests/test_config.py- the API-key contract, and a scan of the whole repository (python and frontend sources alike) proving no key literal is committed.tests/test_api.py- every endpoint and every error code against FastAPI'sTestClient: the health payload's exact shape,api_key_configurednever leaking the key, the one error envelope,specnever carryingdropped, the download store's TTL / cap / re-download behaviour, afile_idthat is not a uuid4 hex never reaching the store or the filesystem, theContent-Dispositionpair for a non-ASCII upload name, and a scan ofbackend/as a whole (cli.pyincluded - that is where--mocklived) proving no module a user can reach imports themock_datatest fixture.tests/test_frontend_guards.py- source-level guards for the frontend, in the suite the project actually runs: the containment rules that keep an unbounded backend string inside its column, and the Korean-UI rules -word-break: keep-all, no particle detached from the word it binds to, no bracketed either/or particle, no0..1000in Korean copy, and Korean copy for every error code the backend can emit (theConversionFailurecodes plusmain._CODE_BY_STATUS, read live).
Quick manual smoke test, no proxy and no key required. --from-json is the one
CLI path that calls no model, so seed it from the suite's spec fixture and the
whole build path - schema validation, native shapes/tables/text boxes, the 16:9
slide - runs with no network at all:
cd image-to-ppt/backend \
&& ../.venv/bin/python -c "from app import mock_data; print(mock_data.SAMPLE_SPEC_JSON)" > /tmp/spec.json \
&& ../.venv/bin/python cli.py --from-json /tmp/spec.json -o /tmp/demo.pptx \
&& open /tmp/demo.pptxTo smoke-test the model path instead, cli.py -i sample.png -o /tmp/demo.pptx
does the same thing end to end - but it needs LITELLM_API_KEY and a reachable
LiteLLM proxy, and it writes no file at all if the analysis fails, so the
&& open half never runs.
port 8000 (backend / uvicorn) is already in use. run.sh refuses to
start rather than fight for the port, and prints the PID and the kill line.
See for yourself and free it:
lsof -nP -iTCP:8000 -sTCP:LISTEN # or -iTCP:5173 for the frontend
kill <pid> # kill -9 <pid> if it ignores thatOr move the service: WEB_PORT=5174 ./run.sh frontend, or
API_PORT=8010 ./run.sh backend (then update the proxy target in
frontend/vite.config.ts so the UI follows).
Ctrl-C left something running. It should not - run.sh traps EXIT,
INT and TERM and kills each child and its descendants, then reports that
both ports are free. If you killed the script with kill -9 (which no trap can
catch), clean up by hand with the lsof / kill pair above.
One shell gotcha if you script it: when run.sh is started as a background
job from a non-interactive shell (./run.sh & inside another script), the shell
enters with SIGINT already ignored, and POSIX says a signal ignored on entry
to a non-interactive shell cannot be trapped - so a later kill -INT does
nothing and the stack keeps running. Use kill -TERM <pid> there, which the
trap does receive. Interactive Ctrl-C is unaffected: the terminal delivers
SIGINT to the whole foreground process group, so the children stop too.
When you check the ports yourself, match listeners only - a plain
lsof -ti :5173 also matches browser tabs holding a client socket to the dev
server, which looks like a leftover process but is not one:
lsof -nP -iTCP:5173 -sTCP:LISTEN # empty output = nothing is serving503 missing_api_key (or the CLI's LITELLM_API_KEY is not set (or is blank)). No key is configured, so no request was sent. Put it in .env at the
repository root, or export it in the shell you start the server from, then
restart:
echo "LITELLM_API_KEY=<your proxy key>" >> .env # git-ignored, auto-loaded
./run.shStill 503 with a key sitting in .env? Check, in this order:
-
Is the file at the repository root and named exactly
.env? Notbackend/.env, not.env.txt.ls -l .envshould show it. -
Did you edit
.env.exampleinstead of.env? The example is only a template; nothing loads it. -
What did the server say at startup? It logs the path it loaded and the names it set.
found ... but set nothing from itmeans those names were already in the environment, which wins -unset LITELLM_API_KEYif a stale blank export is shadowing the file. -
Is the name right?
LITELLM_API_KEY, notLITELLM_APIKEYorOPENAI_API_KEY. A misspelled line is skipped silently. -
Confirm what the app actually sees (prints names, never values):
cd backend && ../.venv/bin/python cli.py -i sample.png -o /tmp/probe.pptx -v 2>&1 | grep '.env' curl -s localhost:8000/api/health | grep -o '"api_key_configured":[a-z]*'
Or stay offline with the spec-driven paths: POST /api/preview,
POST /api/build-from-json or --from-json, none of which need a key.
502 proxy_unreachable / Connection refused. The LiteLLM proxy is not
running, or LITELLM_BASE_URL points somewhere else. Remember the URL needs
the scheme (http://).
curl -s http://localhost:4000/health/readiness
curl -s http://127.0.0.1:8000/api/health # shows the base_url in useMeanwhile POST /api/preview and POST /api/build-from-json still work.
401 / 403 from the proxy (surfaces as 502 analysis_failed).
LITELLM_API_KEY is set but does not match what the proxy expects
(--master_key / its general_settings).
Unknown model - 404 model not found, or "model does not exist" (also
502 analysis_failed). VISION_MODEL must match a name your proxy actually
serves. List them (the proxy authenticates this route too, so pass the key -
from the environment, never typed inline):
BASE="${LITELLM_BASE_URL:-http://localhost:4000/}"
curl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${BASE%/}/v1/models"${BASE%/} strips the trailing slash the default carries; plain
$LITELLM_BASE_URL/v1/models would request //v1/models and get a 404 from the
proxy itself. Override the model per request with the model field, or per run
with -m. In the browser there is no model field any more, so the fix there
is VISION_MODEL in .env plus a backend restart - which is what the Korean
copy for analysis_failed now tells you, since the same code covers a model
that never answered and a model whose answer could not be used.
CORS error in the browser console. The dev UI should never hit this: Vite
serves on :5173 and proxies /api to :8000, so requests are same-origin.
If you see one, you are calling the backend cross-origin - the allow-list is
exactly http://localhost:5173 and http://localhost:3000, so
http://127.0.0.1:5173 (a different origin to a browser) or any other port
will be refused. Use http://localhost:5173, or go through the proxy.
400 unsupported_extension (CLI exit code 2). Only .png, .jpg,
.jpeg and .webp are accepted. Convert first:
cd image-to-ppt
.venv/bin/python -c "from PIL import Image; Image.open('in.tiff').convert('RGB').save('in.png')"400 not_an_image (CLI: has an image extension but is not a readable image, exit code 2). The extension is fine but the bytes are not - a
half-finished download, an HTML error page saved as .png, or a file that was
merely renamed. The decode happens locally before a request is spent, so this
fails in milliseconds rather than coming back as a puzzling 400 from the proxy.
413 file_too_large. Over the 20 MB cap. Downscale it; a vision model gains
nothing from 40 megapixels:
cd image-to-ppt
.venv/bin/python -c "from PIL import Image; im=Image.open('big.png'); im.thumbnail((2000,2000)); im.save('small.png')"404 unknown_download. The download_id expired (30 min), was evicted
(more than 64 conversions since), or the backend restarted - --reload does
that on every edit. Re-run the conversion. If you still have the spec from
the response, POST /api/build-from-json rebuilds the same deck for free.
400 invalid_spec. Every element was dropped, so the deck would have been
blank. The hint lists the per-element reasons; the usual causes are a
bbox missing one of its four components and a text element with no
content.
The model answered with prose, or the JSON failed to parse
(502 analysis_failed). extract_json already strips markdown fences and
surrounding chatter, is brace- and string-literal-aware, and when a reply
contains several JSON objects it picks the spec-shaped one - so this usually
means the model genuinely refused or was cut off. Retry, use
extra_instructions / --extra to be more specific, or point model / -m
at a stronger model. The CLI's -v prints the raw payload diagnostics.
Everything landed in the wrong place / overlaps. The model mixed up the
axes or the z-order. Take the spec from the response (or --save-json), fix
the offending bbox values (x/w against width, y/h against height,
0-1000), and rebuild with POST /api/build-from-json or --from-json - no
tokens spent.
Shapes appear but the text is invisible. A filled foreground shape was
emitted after the text, covering it. Fix the ordering in a saved spec
(background shapes first), or add extra_instructions / --extra "emit background shapes first".
A table hangs off the bottom of the slide. See Why the table font is
small above; lower the table's font_size in the spec, or give it a shorter
bbox with fewer rows.
One element is missing and dropped mentioned it. That is the intended
behaviour: a single unrenderable element is reported and skipped so you still
get the rest of the slide.
npm install / tsc complaints after a git pull. run.sh reinstalls
only when package.json is newer than node_modules. Force it:
cd image-to-ppt/frontend && rm -rf node_modules && npm installThe venv is wrong or half-installed. run.sh skips pip install when
backend/requirements.txt is unchanged and every top-level import resolves;
if it is beyond repair, delete it and let the script rebuild:
cd image-to-ppt && rm -rf .venv && ./run.sh test
{ "background_color": "#FFFFFF", // optional, #RRGGBB "elements": [ // order == z-order (back to front) { "type": "shape", "kind": "rectangle", "bbox": [0, 0, 1000, 150], "fill_color": "#1F3B73", // null/omitted -> no fill at all "border_color": null, // null/omitted -> no outline "border_width": 1.5 }, // points { "type": "shape", "kind": "rounded_rectangle", "bbox": [35, 200, 420, 330], "fill_color": "#F2F5FA", "border_color": "#1F3B73", "border_width": 1.5 }, { "type": "shape", "kind": "circle", "bbox": [890, 30, 80, 90], "fill_color": "#FFB000" }, { "type": "text", "bbox": [35, 40, 640, 70], "content": "Quarterly Platform Review", "font_size": 32, // points; defaults to 12 "font_color": "#FFFFFF", "bold": true, "align": "left" }, // left | center | right { "type": "text", "bbox": [65, 230, 365, 270], "content": "Highlights\n- p95 latency down 38%\n- Two new regions live", "font_size": 14, "align": "left" }, // "\n" -> separate paragraphs { "type": "table", "bbox": [525, 220, 420, 260], "data": [ // row-major, first row = header ["Region", "Users", "Growth"], ["EMEA", "18,400", "+12%"], ["APAC", "9,750", "+27%"] ], "header_bg_color": "#1F3B73", "header_font_bold": true, "font_size": 10 }, { "type": "connector", // NO bbox: two points instead "kind": "elbow", // straight (default) | elbow "start_point": { "x": 120, "y": 360 }, "end_point": [420, 520], // [x, y] is accepted too "arrow_start": false, // default false "arrow_end": true, // default true "dash_style": "dashed", // solid (default) | dashed "color": "#1F3B73", "width_pt": 1.5 }, { "type": "image", // a region CROPPED from the input image "bbox": [600, 200, 300, 400], "description": "team photo" } // -> the picture's alt text ] }