diff --git a/built-in-preview-window-option.png b/built-in-preview-window-option.png new file mode 100644 index 0000000..09b5d85 Binary files /dev/null and b/built-in-preview-window-option.png differ diff --git a/built-in-preview-window.png b/built-in-preview-window.png new file mode 100644 index 0000000..2691b7f Binary files /dev/null and b/built-in-preview-window.png differ diff --git a/src/frontend/Makefile b/src/frontend/Makefile index c922019..06482ed 100644 --- a/src/frontend/Makefile +++ b/src/frontend/Makefile @@ -1,4 +1,4 @@ -OBJECTS=sprotocol.o state.o fs.o incdvi.o myabort.o renderer.o engine_tex.o engine_pdf.o engine_dvi.o synctex.o prot_parser.o sexp_parser.o json_parser.o editor.o +OBJECTS=sprotocol.o state.o fs.o incdvi.o myabort.o renderer.o engine_tex.o engine_pdf.o engine_dvi.o synctex.o prot_parser.o sexp_parser.o json_parser.o editor.o webview_output.o BUILD=../../build DIR=$(BUILD)/frontend diff --git a/src/frontend/driver.c b/src/frontend/driver.c index 50add91..b77cbca 100644 --- a/src/frontend/driver.c +++ b/src/frontend/driver.c @@ -106,7 +106,7 @@ static void usage(void) { fprintf(stderr, "Usage: texpresso [-I path]* [-json] [-lines] [-texlive] [-tectonic] " - "[-test-initialize] [-stream] root_file.tex\n"); + "[-test-initialize] [-stream] [-webview] [-tmpdir path] [-resolution N] root_file.tex\n"); fprintf(stderr, " -I path Add a path to included directories. \n" " Files are looked up relative to document directory and all " @@ -125,6 +125,12 @@ static void usage(void) " -test-initialize Run a single cycle for test purposes\n"); fprintf(stderr, " -stream Skip filesystem lookups; files are pushed via editor commands\n"); + fprintf(stderr, + " -webview Run in webview mode (output QOI files via stdout, no SDL window)\n"); + fprintf(stderr, + " -tmpdir Set temporary directory for QOI output files\n"); + fprintf(stderr, + " -resolution N Default rendering resolution multiplier (default 2.5)\n"); } int main(int argc, const char **argv) @@ -155,6 +161,9 @@ int main(int argc, const char **argv) bool use_texlive = 0; bool initialize_only = 0; bool stream_mode = 0; + bool webview_mode = 0; + float default_resolution = 2.5f; + char tmpdir[4096] = {0}; int inclusion_path_size = 1; for (int i = 1; i < argc; i++) @@ -198,6 +207,33 @@ int main(int argc, const char **argv) { stream_mode = 1; } + else if (strcmp(arg, "-webview") == 0) + { + webview_mode = 1; + } + else if (strcmp(arg, "-tmpdir") == 0) + { + i += 1; + if (i == argc) + { + fprintf(stderr, "[error] Expecting a path after -tmpdir\n"); + usage(); + exit(1); + } + snprintf(tmpdir, sizeof(tmpdir), "%s", argv[i]); + } + else if (strcmp(arg, "-resolution") == 0) + { + i += 1; + if (i == argc) + { + fprintf(stderr, "[error] Expecting a number after -resolution\n"); + usage(); + exit(1); + } + default_resolution = atof(argv[i]); + if (default_resolution <= 0) default_resolution = 2.5f; + } else { fprintf(stderr, "[error] Unknown option %s\n", arg); @@ -283,43 +319,59 @@ int main(int argc, const char **argv) bool init = 0; //Initialize SDL - if (init == 0 && SDL_Init(SDL_INIT_VIDEO) < 0) + if (webview_mode) { - fprintf(stderr, "SDL could not initialize! SDL_Error: %s\n", SDL_GetError()); - abort(); + if (init == 0 && SDL_Init(SDL_INIT_TIMER | SDL_INIT_EVENTS) < 0) + { + fprintf(stderr, "SDL could not initialize! SDL_Error: %s\n", SDL_GetError()); + abort(); + } + } + else + { + if (init == 0 && SDL_Init(SDL_INIT_VIDEO) < 0) + { + fprintf(stderr, "SDL could not initialize! SDL_Error: %s\n", SDL_GetError()); + abort(); + } } custom_event = SDL_RegisterEvents(1); signal(SIGUSR1, signal_usr1); - //Create window - char window_title[128] = "TeXpresso "; - strcat(window_title, doc_name); + SDL_Window *window = NULL; + SDL_Renderer *renderer = NULL; + + if (!webview_mode) + { + //Create window + char window_title[128]; + snprintf(window_title, sizeof(window_title), "TeXpresso %s", doc_name); #if SDL_VERSION_ATLEAST(2, 0, 8) - SDL_SetHint(SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, "0"); + SDL_SetHint(SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, "0"); #endif - SDL_Window *window; - window = SDL_CreateWindow(window_title, - SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, - 700, 900, - SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE - ); + window = SDL_CreateWindow(window_title, + SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + 700, 900, + SDL_WINDOW_HIDDEN | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE + ); - if (window == NULL) - { - fprintf(stderr, "Window could not be created! SDL_Error: %s\n", SDL_GetError() ); - abort(); - } + if (window == NULL) + { + fprintf(stderr, "Window could not be created! SDL_Error: %s\n", SDL_GetError() ); + abort(); + } - SDL_Surface *logo = texpresso_logo(); - fprintf(stderr, "texpresso logo: %dx%d\n", logo->w, logo->h); - SDL_SetWindowIcon(window, logo); - SDL_FreeSurface(logo); + SDL_Surface *logo = texpresso_logo(); + fprintf(stderr, "texpresso logo: %dx%d\n", logo->w, logo->h); + SDL_SetWindowIcon(window, logo); + SDL_FreeSurface(logo); - SDL_Renderer *renderer; - renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_PRESENTVSYNC | SDL_RENDERER_TARGETTEXTURE); + renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_PRESENTVSYNC | SDL_RENDERER_TARGETTEXTURE); + SDL_ShowWindow(window); + } struct persistent_state pstate = { .initial = {0,}, @@ -339,8 +391,13 @@ int main(int argc, const char **argv) .use_tectonic = use_tectonic, .use_texlive = use_texlive, .initialize_only = initialize_only, - .stream_mode = stream_mode + .stream_mode = stream_mode, + .webview_mode = webview_mode, + .default_resolution = default_resolution, + .render_width = 0, + .render_height = 0, }; + memcpy(pstate.tmpdir, tmpdir, sizeof(tmpdir)); int exit_code = 0; @@ -355,8 +412,10 @@ int main(int argc, const char **argv) ; } - SDL_DestroyRenderer(renderer); - SDL_DestroyWindow(window); + if (!webview_mode) { + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + } SDL_Quit(); fz_drop_context(ctx); diff --git a/src/frontend/driver.h b/src/frontend/driver.h index 229c745..c48edaa 100644 --- a/src/frontend/driver.h +++ b/src/frontend/driver.h @@ -70,6 +70,13 @@ struct persistent_state { const char *exe_path, *doc_path, *doc_name, *inclusion_path; bool line_output, use_tectonic, use_texlive, initialize_only, stream_mode; + + bool webview_mode; + bool dark_mode; + float default_resolution; + int render_width; + int render_height; + char tmpdir[4096]; }; bool texpresso_main(struct persistent_state *ps); diff --git a/src/frontend/editor.c b/src/frontend/editor.c index dac5b1d..140348d 100644 --- a/src/frontend/editor.c +++ b/src/frontend/editor.c @@ -281,6 +281,60 @@ bool editor_parse(fz_context *ctx, goto arity; *out = (struct editor_command){.tag = EDIT_INVERT, .invert = {}}; } + else if (strcmp(verb, "synctex-backward") == 0) + { + if (len != 4) goto arity; + *out = (struct editor_command){ + .tag = EDIT_SYNCTEX_BACKWARD, + .synctex_backward = { + .page = val_number(ctx, val_array_get(ctx, stack, command, 1)), + .x = val_number(ctx, val_array_get(ctx, stack, command, 2)), + .y = val_number(ctx, val_array_get(ctx, stack, command, 3)), + }}; + } + else if (strcmp(verb, "set-page") == 0) + { + if (len != 2) goto arity; + *out = (struct editor_command){ + .tag = EDIT_SET_PAGE, + .set_page = { + .page = val_number(ctx, val_array_get(ctx, stack, command, 1)), + }}; + } + else if (strcmp(verb, "set-output-size") == 0) + { + if (len != 3) goto arity; + *out = (struct editor_command){ + .tag = EDIT_SET_OUTPUT_SIZE, + .set_output_size = { + .width = val_number(ctx, val_array_get(ctx, stack, command, 1)), + .height = val_number(ctx, val_array_get(ctx, stack, command, 2)), + }}; + } + else if (strcmp(verb, "go-home") == 0) + { + if (len != 1) goto arity; + *out = (struct editor_command){.tag = EDIT_GO_HOME, .go_home = {}}; + } + else if (strcmp(verb, "go-end") == 0) + { + if (len != 1) goto arity; + *out = (struct editor_command){.tag = EDIT_GO_END, .go_end = {}}; + } + else if (strcmp(verb, "reset-zoom") == 0) + { + if (len != 1) goto arity; + *out = (struct editor_command){.tag = EDIT_RESET_ZOOM, .reset_zoom = {}}; + } + else if (strcmp(verb, "set-fit-mode") == 0) + { + if (len != 2) goto arity; + val mode = val_array_get(ctx, stack, command, 1); + if (!val_is_string(mode)) goto arguments; + *out = (struct editor_command){.tag = EDIT_SET_FIT_MODE}; + const char *s = val_string(ctx, stack, mode); + snprintf(out->set_fit_mode.mode, sizeof(out->set_fit_mode.mode), "%s", s); + } else { fprintf(stderr, "[command] unknown verb: %s\n", verb); diff --git a/src/frontend/editor.h b/src/frontend/editor.h index 6aef93f..2982179 100644 --- a/src/frontend/editor.h +++ b/src/frontend/editor.h @@ -32,6 +32,13 @@ enum EDITOR_COMMAND EDIT_UNMAP_WINDOW, EDIT_CROP, EDIT_INVERT, + EDIT_SYNCTEX_BACKWARD, + EDIT_SET_PAGE, + EDIT_SET_OUTPUT_SIZE, + EDIT_GO_HOME, + EDIT_GO_END, + EDIT_RESET_ZOOM, + EDIT_SET_FIT_MODE, }; struct editor_change @@ -114,6 +121,32 @@ struct editor_command struct { } invert; + + struct { + int page; + float x, y; + } synctex_backward; + + struct { + int page; + } set_page; + + struct { + int width, height; + } set_output_size; + + struct { + } go_home; + + struct { + } go_end; + + struct { + } reset_zoom; + + struct { + char mode[8]; + } set_fit_mode; }; }; diff --git a/src/frontend/main.c b/src/frontend/main.c index cbe436a..7d0396a 100644 --- a/src/frontend/main.c +++ b/src/frontend/main.c @@ -39,6 +39,8 @@ #include "prot_parser.h" #include "editor.h" #include "base64.h" +#include "webview_output.h" +#include "qoi.h" struct persistent_state *pstate; @@ -1039,12 +1041,100 @@ static void interpret_command(struct persistent_state *ps, break; case EDIT_INVERT: { - txp_renderer_config *config = - txp_renderer_get_config(ps->ctx, ui->doc_renderer); - config->invert_color = !config->invert_color; - schedule_event(RENDER_EVENT); + if (ps->webview_mode) { + ps->dark_mode = !ps->dark_mode; + fprintf(stdout, "[\"dark-mode\",%s]\n", ps->dark_mode ? "true" : "false"); + fflush(stdout); + schedule_event(RELOAD_EVENT); + } else { + txp_renderer_config *config = + txp_renderer_get_config(ps->ctx, ui->doc_renderer); + config->invert_color = !config->invert_color; + schedule_event(RENDER_EVENT); + } } break; + + case EDIT_SYNCTEX_BACKWARD: + { + fz_buffer *buf; + synctex_t *stx = send(synctex, ui->eng, &buf); + int page = cmd.synctex_backward.page; + float x = cmd.synctex_backward.x; + float y = cmd.synctex_backward.y; + // Webview sends coordinates in TeX points. + // synctex_scan expects DVI internal units, so divide by scale_factor. + float f = send(scale_factor, ui->eng); + if (f == 0) f = 1; + synctex_scan(ps->ctx, stx, buf, ps->doc_path, page, x / f, y / f); + } + break; + + case EDIT_GO_HOME: + ui->page = 0; + schedule_event(RELOAD_EVENT); + break; + + case EDIT_GO_END: + { + // Poll engine until terminated (~500ms timeout to avoid blocking UI) + int stalled = 0; + while (send(get_status, ui->eng) == DOC_RUNNING && stalled < 50000) + { + if (send(step, ui->eng, ps->ctx, false)) + stalled = 0; + else + stalled++; + } + int count = send(page_count, ui->eng); + if (count > 0) + ui->page = count - 1; + schedule_event(RELOAD_EVENT); + } + break; + + case EDIT_SET_PAGE: + { + int page = cmd.set_page.page; + // Poll engine until the requested page is discovered or engine terminates (~500ms timeout) + int stalled = 0; + while (send(page_count, ui->eng) <= page && send(get_status, ui->eng) == DOC_RUNNING && stalled < 50000) + { + if (send(step, ui->eng, ps->ctx, false)) + stalled = 0; + else + stalled++; + } + int count = send(page_count, ui->eng); + if (page >= 0 && page < count) + { + ui->page = page; + schedule_event(RELOAD_EVENT); + } + else + { + // Page doesn't exist — notify the webview + fprintf(stdout, "[\"page-error\",%d]\n", count); + fflush(stdout); + } + } + break; + + case EDIT_SET_OUTPUT_SIZE: + ps->render_width = cmd.set_output_size.width; + ps->render_height = cmd.set_output_size.height; + schedule_event(RELOAD_EVENT); + break; + + case EDIT_RESET_ZOOM: + fprintf(stdout, "[\"zoom-reset\"]\n"); + fflush(stdout); + break; + + case EDIT_SET_FIT_MODE: + fprintf(stdout, "[\"fit-mode-changed\",\"%s\"]\n", cmd.set_fit_mode.mode); + fflush(stdout); + break; } } @@ -1120,16 +1210,26 @@ bool texpresso_main(struct persistent_state *ps) } ui->sdl_renderer = ps->renderer; - ui->doc_renderer = txp_renderer_new(ps->ctx, ui->sdl_renderer); + if (ps->webview_mode) + { + ui->doc_renderer = NULL; // No SDL renderer in webview mode + } + else + { + ui->doc_renderer = txp_renderer_new(ps->ctx, ui->sdl_renderer); + } if (ps->initial.initialized) { ui->page = ps->initial.page; ui->zoom = ps->initial.zoom; ui->need_synctex = ps->initial.need_synctex; - *txp_renderer_get_config(ps->ctx, ui->doc_renderer) = ps->initial.config; - txp_renderer_set_contents(ps->ctx, ui->doc_renderer, - ps->initial.display_list); + if (ui->doc_renderer) + { + *txp_renderer_get_config(ps->ctx, ui->doc_renderer) = ps->initial.config; + txp_renderer_set_contents(ps->ctx, ui->doc_renderer, + ps->initial.display_list); + } editor_reset_sync(); } else @@ -1142,15 +1242,20 @@ bool texpresso_main(struct persistent_state *ps) ui->mouse_status = UI_MOUSE_NONE; ui->last_mouse_x = -1000; ui->last_mouse_y = -1000; - ui->last_click_ticks = SDL_GetTicks() - 200000000; + ui->last_click_ticks = SDL_GetTicks() - 50000000; bool quit = 0, reload = 0; send(step, ui->eng, ps->ctx, true); - render(ps->ctx, ui); + if (!ps->webview_mode) + render(ps->ctx, ui); schedule_event(RELOAD_EVENT); - struct repaint_on_resize_env repaint_on_resize_env = {.ctx = ps->ctx, .ui = ui}; - SDL_AddEventWatch(repaint_on_resize, &repaint_on_resize_env); + struct repaint_on_resize_env repaint_on_resize_env; + if (!ps->webview_mode) + { + repaint_on_resize_env = (struct repaint_on_resize_env){.ctx = ps->ctx, .ui = ui}; + SDL_AddEventWatch(repaint_on_resize, &repaint_on_resize_env); + } vstack *cmd_stack = vstack_new(ps->ctx); prot_parser cmd_parser; @@ -1172,6 +1277,7 @@ bool texpresso_main(struct persistent_state *ps) { SDL_Event e; bool has_event = SDL_PollEvent(&e); + bool webview_rendered_this_iteration = false; // Process stdin send(begin_changes, ui->eng, ps->ctx); @@ -1213,10 +1319,12 @@ bool texpresso_main(struct persistent_state *ps) } if (n == 0) stdin_eof = 1; + bool had_changes = false; if (send(end_changes, ui->eng, ps->ctx)) { send(step, ui->eng, ps->ctx, true); schedule_event(RELOAD_EVENT); + had_changes = true; } // Process document @@ -1229,6 +1337,33 @@ bool texpresso_main(struct persistent_state *ps) if (ui->page >= before_page_count && ui->page < after_page_count) schedule_event(RELOAD_EVENT); + // Immediate render for real-time editing feedback (only on content changes, not during preload) + if (ps->webview_mode && ui->page < after_page_count && had_changes) + { + int w = ps->render_width; + int h = ps->render_height; + int pw = 0, ph = 0; + if (w == 0 || h == 0) + { + fz_display_list *dl = send(render_page, ui->eng, ps->ctx, ui->page); + if (dl) + { + fz_rect bounds = fz_bound_display_list(ps->ctx, dl); + pw = (int)(bounds.x1 - bounds.x0); + ph = (int)(bounds.y1 - bounds.y0); + fz_drop_display_list(ps->ctx, dl); + } + if (pw == 0) pw = 612; + if (ph == 0) ph = 792; + w = (int)(pw * ps->default_resolution); + h = (int)(ph * ps->default_resolution); + } + webview_output_page(ps->ctx, ui->eng, ui->page, after_page_count, + w, h, pw, ph, + ps->tmpdir[0] ? ps->tmpdir : NULL, ps->dark_mode); + webview_rendered_this_iteration = true; + } + if (!has_event) { if (advance) @@ -1254,43 +1389,59 @@ bool texpresso_main(struct persistent_state *ps) if (page != ui->page) { ui->page = page; - display_page(ps, ui); + if (ps->webview_mode) + schedule_event(RELOAD_EVENT); + else + display_page(ps, ui); } - // FIXME: Scroll to point - float f = send(scale_factor, ui->eng); - fz_point p = fz_make_point(f * x, f * y); - fz_point pt = txp_renderer_document_to_screen(ps->ctx, ui->doc_renderer, p); - fprintf(stderr, "[synctex forward] position on screen: (%.02f, %.02f)\n", - pt.x, pt.y); - int w, h; - txp_renderer_screen_size(ps->ctx, ui->doc_renderer, &w, &h); - float margin_lo = h / 4.0; - float margin_hi = h / 3.0; - - txp_renderer_config *config = - txp_renderer_get_config(ps->ctx, ui->doc_renderer); + // In webview mode, tell the preview to scroll to the synctex position + if (ps->webview_mode) + { + float f = send(scale_factor, ui->eng); + if (f == 0) f = 1; + fprintf(stdout, "[\"synctex-scroll\",%f,%f]\n", f * x, f * y); + fflush(stdout); + } - float delta = 0.0; - if (pt.y < margin_lo) - delta = - pt.y + margin_hi; - else if (pt.y >= h - margin_lo) - delta = h - pt.y - margin_hi; - fprintf(stderr, "[synctex forward] pan.y = %.02f + %.02f = %.02f\n", - config->pan.y, delta, config->pan.y + delta); - config->pan.y += delta; - if (delta != 0.0) - schedule_event(RENDER_EVENT); + if (!ps->webview_mode && ui->doc_renderer) { + // Scroll to point (SDL mode only) + float f = send(scale_factor, ui->eng); + fz_point p = fz_make_point(f * x, f * y); + fz_point pt = txp_renderer_document_to_screen(ps->ctx, ui->doc_renderer, p); + fprintf(stderr, "[synctex forward] position on screen: (%.02f, %.02f)\n", + pt.x, pt.y); + int w, h; + txp_renderer_screen_size(ps->ctx, ui->doc_renderer, &w, &h); + float margin_lo = h / 4.0; + float margin_hi = h / 3.0; + + txp_renderer_config *config = + txp_renderer_get_config(ps->ctx, ui->doc_renderer); + + float delta = 0.0; + if (pt.y < margin_lo) + delta = - pt.y + margin_hi; + else if (pt.y >= h - margin_lo) + delta = h - pt.y - margin_hi; + fprintf(stderr, "[synctex forward] pan.y = %.02f + %.02f = %.02f\n", + config->pan.y, delta, config->pan.y + delta); + config->pan.y += delta; + if (delta != 0.0) + schedule_event(RENDER_EVENT); + } } } - txp_renderer_set_scale_factor(ps->ctx, ui->doc_renderer, - get_scale_factor(ui->window)); - txp_renderer_config *config = - txp_renderer_get_config(ps->ctx, ui->doc_renderer); + if (!ps->webview_mode) + { + txp_renderer_set_scale_factor(ps->ctx, ui->doc_renderer, + get_scale_factor(ui->window)); + txp_renderer_config *config = + txp_renderer_get_config(ps->ctx, ui->doc_renderer); - // Process event - switch (e.type) + // Process event (SDL events only in non-webview mode) + switch (e.type) { case SDL_QUIT: quit = 1; @@ -1410,7 +1561,8 @@ bool texpresso_main(struct persistent_state *ps) break; } break; - } + } // end of switch + } // end of if (!ps->webview_mode) if (e.type == ps->custom_event) { @@ -1446,6 +1598,9 @@ bool texpresso_main(struct persistent_state *ps) break; case RELOAD_EVENT: + // In webview mode, skip if already rendered inline this iteration + if (ps->webview_mode && webview_rendered_this_iteration) + break; page_count = send(page_count, ui->eng); if (ui->page >= page_count && send(get_status, ui->eng) == DOC_TERMINATED) @@ -1454,7 +1609,36 @@ bool texpresso_main(struct persistent_state *ps) ui->page = page_count - 1; } if (ui->page < page_count) - display_page(ps, ui); + { + if (ps->webview_mode) + { + int w = ps->render_width; + int h = ps->render_height; + int pw = 0, ph = 0; + if (w == 0 || h == 0) + { + fz_display_list *dl = send(render_page, ui->eng, ps->ctx, ui->page); + if (dl) + { + fz_rect bounds = fz_bound_display_list(ps->ctx, dl); + pw = (int)(bounds.x1 - bounds.x0); + ph = (int)(bounds.y1 - bounds.y0); + fz_drop_display_list(ps->ctx, dl); + } + if (pw == 0) pw = 612; + if (ph == 0) ph = 792; + w = (int)(pw * ps->default_resolution); + h = (int)(ph * ps->default_resolution); + } + webview_output_page(ps->ctx, ui->eng, ui->page, page_count, + w, h, pw, ph, + ps->tmpdir[0] ? ps->tmpdir : NULL, ps->dark_mode); + } + else + { + display_page(ps, ui); + } + } break; case STDIN_EVENT: @@ -1476,7 +1660,8 @@ bool texpresso_main(struct persistent_state *ps) close(poll_stdin_pipe[1]); } - SDL_DelEventWatch(repaint_on_resize, &repaint_on_resize_env); + if (!ps->webview_mode) + SDL_DelEventWatch(repaint_on_resize, &repaint_on_resize_env); if (ps->initial.initialized && ps->initial.display_list) fz_drop_display_list(ps->ctx, ps->initial.display_list); @@ -1484,12 +1669,14 @@ bool texpresso_main(struct persistent_state *ps) ps->initial.page = ui->page; ps->initial.need_synctex = ui->need_synctex; ps->initial.zoom = ui->zoom; - ps->initial.config = *txp_renderer_get_config(ps->ctx, ui->doc_renderer); - ps->initial.display_list = txp_renderer_get_contents(ps->ctx, ui->doc_renderer); - if (ps->initial.display_list) - fz_keep_display_list(ps->ctx, ps->initial.display_list); - - txp_renderer_free(ps->ctx, ui->doc_renderer); + if (ui->doc_renderer) + { + ps->initial.config = *txp_renderer_get_config(ps->ctx, ui->doc_renderer); + ps->initial.display_list = txp_renderer_get_contents(ps->ctx, ui->doc_renderer); + if (ps->initial.display_list) + fz_keep_display_list(ps->ctx, ps->initial.display_list); + txp_renderer_free(ps->ctx, ui->doc_renderer); + } send(destroy, ui->eng, ps->ctx); return reload; diff --git a/src/frontend/renderer.c b/src/frontend/renderer.c index 9549248..9f42415 100644 --- a/src/frontend/renderer.c +++ b/src/frontend/renderer.c @@ -317,7 +317,7 @@ static int fz_irect_area(fz_irect r) #define remap(v, bp, wp) (bp) + ((v) * (wp - bp)) / 255 -static void invert_pixmap(fz_context *ctx, fz_pixmap *pix, uint32_t black, uint32_t white) +void txp_renderer_invert_pixmap(fz_context *ctx, fz_pixmap *pix, uint32_t black, uint32_t white) { uint8_t *data0 = fz_pixmap_samples(ctx, pix); int stride = fz_pixmap_stride(ctx, pix); @@ -378,7 +378,7 @@ static void render_rect(fz_context *ctx, txp_renderer *self, fz_rect bounds, voi uint32_t bg, fg; txp_get_colors(&self->config, &bg, &fg); //if (bg != 0x00FFFFFF || fg != 0x00000000) - invert_pixmap(ctx, pm, fg, bg); + txp_renderer_invert_pixmap(ctx, pm, fg, bg); fz_drop_pixmap(ctx, pm); } @@ -995,3 +995,43 @@ void txp_renderer_screen_size(fz_context *ctx, txp_renderer *self, int *w, int * *w = self->output_w; *h = self->output_h; } + +fz_pixmap *txp_renderer_render_to_pixmap(fz_context *ctx, fz_display_list *dl, + int width, int height, + uint32_t bg_color, uint32_t fg_color) +{ + fz_irect bbox = fz_make_irect(0, 0, width, height); + fz_pixmap *pix = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), bbox, NULL, 0); + if (!pix) return NULL; + + // Fill with background color (white by default) + fz_clear_pixmap_with_value(ctx, pix, 0xFF); + + // Compute scale to fit the content into the pixmap + fz_rect bounds = fz_bound_display_list(ctx, dl); + float doc_w = bounds.x1 - bounds.x0; + float doc_h = bounds.y1 - bounds.y0; + if (doc_w <= 0) doc_w = width; + if (doc_h <= 0) doc_h = height; + + float scale_x = (float)width / doc_w; + float scale_y = (float)height / doc_h; + float scale = fz_min(scale_x, scale_y); + + // Center the content + float tx = (width - doc_w * scale) / 2.0f; + float ty = (height - doc_h * scale) / 2.0f; + + fz_matrix ctm = fz_translate(tx, ty); + ctm = fz_concat(ctm, fz_scale(scale, scale)); + ctm = fz_concat(ctm, fz_translate(-bounds.x0, -bounds.y0)); + + fz_device *dev = fz_new_draw_device(ctx, ctm, pix); + fz_run_display_list(ctx, dl, dev, fz_identity, fz_infinite_rect, NULL); + fz_close_device(ctx, dev); + fz_drop_device(ctx, dev); + + txp_renderer_invert_pixmap(ctx, pix, bg_color, fg_color); + + return pix; +} diff --git a/src/frontend/renderer.h b/src/frontend/renderer.h index 47f14f8..a7104ef 100644 --- a/src/frontend/renderer.h +++ b/src/frontend/renderer.h @@ -81,5 +81,10 @@ bool txp_renderer_select_char(fz_context *ctx, txp_renderer *self, fz_point pt); void txp_renderer_screen_size(fz_context *ctx, txp_renderer *self, int *w, int *h); fz_point txp_renderer_screen_to_document(fz_context *ctx, txp_renderer *self, fz_point pt); fz_point txp_renderer_document_to_screen(fz_context *ctx, txp_renderer *self, fz_point pt); +fz_pixmap *txp_renderer_render_to_pixmap(fz_context *ctx, fz_display_list *dl, + int width, int height, + uint32_t bg_color, uint32_t fg_color); +void txp_renderer_invert_pixmap(fz_context *ctx, fz_pixmap *pix, + uint32_t black, uint32_t white); #endif /*!_RENDERER_H_*/ diff --git a/src/frontend/webview_output.c b/src/frontend/webview_output.c new file mode 100644 index 0000000..6042447 --- /dev/null +++ b/src/frontend/webview_output.c @@ -0,0 +1,313 @@ +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#else +#include +#endif + +#include "driver.h" +#include "renderer.h" +#include "engine.h" + +#include "qoi.h" + +static char *g_webview_tmpdir = NULL; + +// Incremental render state +static unsigned char *prev_rgb = NULL; +static int prev_w = 0, prev_h = 0; +static int prev_page = -1; + +void webview_set_tmpdir(const char *dir) +{ + g_webview_tmpdir = strdup(dir); +} + +// Write all bytes to fd, handling partial writes and EINTR +static bool write_all(int fd, const void *data, size_t len) +{ + const unsigned char *p = data; + while (len > 0) { + ssize_t n = write(fd, p, len); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + p += n; + len -= n; + } + return true; +} + +static void write_qoi_file(const char *tmpdir, unsigned char *rgb, + int w, int h, char *path_out, size_t path_sz) +{ + qoi_desc desc = { .width = w, .height = h, .channels = 3, .colorspace = QOI_SRGB }; + int qoi_len = 0; + void *qoi_data = qoi_encode(rgb, &desc, &qoi_len); + if (!qoi_data) { + fprintf(stderr, "[webview] ERROR: qoi_encode returned NULL\n"); + return; + } + + snprintf(path_out, path_sz, "%s/texpresso-XXXXXX", tmpdir); + int fd = mkstemp(path_out); + if (fd < 0) { + fprintf(stderr, "[webview] ERROR: mkstemp(%s) failed: %s\n", path_out, strerror(errno)); + free(qoi_data); + path_out[0] = '\0'; + return; + } + + if (!write_all(fd, qoi_data, qoi_len)) { + fprintf(stderr, "[webview] ERROR: write_all failed: %s\n", strerror(errno)); + close(fd); + unlink(path_out); + free(qoi_data); + path_out[0] = '\0'; + return; + } + close(fd); + free(qoi_data); +} + +#define MAX_DIRTY_RECTS 16 +#define DIRTY_RATIO_THRESHOLD 0.5f + +typedef struct { + int x, y, w, h; +} dirty_rect_t; + +static int compute_dirty_rects(unsigned char *old_rgb, unsigned char *new_rgb, + int w, int h, dirty_rect_t *rects, int max_rects, + float *dirty_ratio) +{ + int total_pixels = w * h; + int dirty_pixels = 0; + int rect_count = 0; + + // For tall pages (>4096px), fall back to full-page update rather + // than missing changes below the fixed array limit. + if (h > 4096) { + *dirty_ratio = 1.0f; + return -1; + } + + int row_min_x[4096]; + int row_max_x[4096]; + int dirty_start = -1; + + for (int y = 0; y < h; y++) { + unsigned char *old_row = old_rgb + y * w * 3; + unsigned char *new_row = new_rgb + y * w * 3; + int min_x = w, max_x = -1; + + for (int x = 0; x < w; x++) { + int idx = x * 3; + if (old_row[idx] != new_row[idx] || + old_row[idx+1] != new_row[idx+1] || + old_row[idx+2] != new_row[idx+2]) { + if (x < min_x) min_x = x; + max_x = x; + dirty_pixels++; + } + } + + row_min_x[y] = min_x; + row_max_x[y] = max_x; + + if (max_x >= 0 && dirty_start < 0) { + dirty_start = y; + } + + if (dirty_start >= 0 && (max_x < 0 || y == h - 1)) { + int end_y = (max_x >= 0) ? y : y - 1; + + int rx = w, ry = dirty_start, rw = 0, rh = end_y - dirty_start + 1; + for (int ry2 = dirty_start; ry2 <= end_y; ry2++) { + if (row_min_x[ry2] < rx) rx = row_min_x[ry2]; + if (row_max_x[ry2] + 1 - rx > rw) rw = row_max_x[ry2] + 1 - rx; + } + + if (rw > 0 && rh > 0 && rect_count < max_rects) { + rects[rect_count].x = rx; + rects[rect_count].y = ry; + rects[rect_count].w = rw; + rects[rect_count].h = rh; + rect_count++; + } else if (rect_count >= max_rects) { + *dirty_ratio = 1.0f; + return -1; + } + + dirty_start = -1; + } + } + + *dirty_ratio = (float)dirty_pixels / (float)total_pixels; + return rect_count; +} + +// Write a JSON string value safely (escapes ", \, and control chars) +static void write_json_string(FILE *f, const char *s) +{ + putc('"', f); + for (; *s; s++) { + unsigned char c = *s; + if (c == '"' || c == '\\') { putc('\\', f); putc(c, f); } + else if (c == '\n') { fputs("\\n", f); } + else if (c == '\r') { fputs("\\r", f); } + else if (c == '\t') { fputs("\\t", f); } + else if (c < 0x20) { fprintf(f, "\\u%04X", c); } + else { putc(c, f); } + } + putc('"', f); +} + +void webview_output_page(fz_context *ctx, txp_engine *eng, + int page, int total_pages, + int img_width, int img_height, + int page_width, int page_height, + const char *tmpdir, bool dark_mode) +{ + if (!tmpdir) tmpdir = g_webview_tmpdir ? g_webview_tmpdir : "/tmp"; + + fz_display_list *dl = send(render_page, eng, ctx, page); + if (!dl) { + fprintf(stderr, "[webview] ERROR: send(render_page) returned NULL for page %d\n", page); + return; + } + + uint32_t bg, fg; + if (dark_mode) { + bg = 0x00FFFFFF; fg = 0x00000000; + } else { + bg = 0x00000000; fg = 0x00FFFFFF; + } + fz_pixmap *pix = txp_renderer_render_to_pixmap(ctx, dl, img_width, img_height, bg, fg); + fz_drop_display_list(ctx, dl); + if (!pix) { + fprintf(stderr, "[webview] ERROR: render_to_pixmap returned NULL\n"); + return; + } + + unsigned char *samples = fz_pixmap_samples(ctx, pix); + int w = fz_pixmap_width(ctx, pix); + int h = fz_pixmap_height(ctx, pix); + int n = fz_pixmap_components(ctx, pix); + int stride = fz_pixmap_stride(ctx, pix); + + unsigned char *rgb = malloc(w * h * 3); + if (!rgb) { + fprintf(stderr, "[webview] ERROR: malloc(%d) failed\n", w * h * 3); + fz_drop_pixmap(ctx, pix); + return; + } + + if (stride == w * n && n == 3) { + memcpy(rgb, samples, (size_t)w * h * 3); + } else { + for (int y = 0; y < h; y++) { + unsigned char *src = samples + stride * y; + unsigned char *dst = rgb + w * 3 * y; + for (int x = 0; x < w; x++) { + dst[x * 3 + 0] = src[x * n + 0]; + dst[x * 3 + 1] = src[x * n + 1]; + dst[x * 3 + 2] = src[x * n + 2]; + } + } + } + + fz_drop_pixmap(ctx, pix); + + bool send_update = true; + bool is_diff = false; + if (prev_rgb && prev_w == w && prev_h == h && prev_page == page) { + dirty_rect_t rects[MAX_DIRTY_RECTS]; + float dirty_ratio = 0; + int n_rects = compute_dirty_rects(prev_rgb, rgb, w, h, rects, MAX_DIRTY_RECTS, &dirty_ratio); + if (n_rects == 0) { + send_update = false; + } else if (n_rects > 0 && dirty_ratio < DIRTY_RATIO_THRESHOLD) { + is_diff = true; + + // Build a list of successfully prepared rects before emitting JSON + struct { int x, y, w, h; char path[PATH_MAX]; } emitted[MAX_DIRTY_RECTS]; + int emitted_count = 0; + for (int i = 0; i < n_rects; i++) { + dirty_rect_t *r = &rects[i]; + int rw = r->w, rh = r->h; + unsigned char *rect_rgb = malloc(rw * rh * 3); + if (!rect_rgb) continue; + for (int ry = 0; ry < rh; ry++) { + memcpy(rect_rgb + ry * rw * 3, + rgb + ((r->y + ry) * w + r->x) * 3, + rw * 3); + } + qoi_desc rdesc = { .width = rw, .height = rh, .channels = 3, .colorspace = QOI_SRGB }; + int rqoi_len = 0; + void *rqoi_data = qoi_encode(rect_rgb, &rdesc, &rqoi_len); + free(rect_rgb); + if (!rqoi_data) continue; + + char rpath[PATH_MAX]; + snprintf(rpath, sizeof(rpath), "%s/texpresso-XXXXXX", tmpdir); + int rfd = mkstemp(rpath); + if (rfd < 0) { free(rqoi_data); continue; } + if (!write_all(rfd, rqoi_data, rqoi_len)) { + close(rfd); + unlink(rpath); + free(rqoi_data); + continue; + } + close(rfd); + free(rqoi_data); + + emitted[emitted_count].x = r->x; + emitted[emitted_count].y = r->y; + emitted[emitted_count].w = rw; + emitted[emitted_count].h = rh; + memcpy(emitted[emitted_count].path, rpath, sizeof(rpath)); + emitted_count++; + } + + if (emitted_count > 0) { + fprintf(stdout, "[\"page-diff\",%d,%d,%d,%d,%d,%d,%d,[", + page, total_pages, w, h, page_width, page_height, emitted_count); + for (int i = 0; i < emitted_count; i++) { + if (i > 0) fprintf(stdout, ","); + fprintf(stdout, "[%d,%d,%d,%d,", + emitted[i].x, emitted[i].y, emitted[i].w, emitted[i].h); + write_json_string(stdout, emitted[i].path); + fprintf(stdout, "]"); + } + fprintf(stdout, "]]\n"); + fflush(stdout); + } + // If all rects failed, fall through to full page below + } + } + + if (send_update && !is_diff) { + char tmppath[PATH_MAX]; + write_qoi_file(tmpdir, rgb, w, h, tmppath, sizeof(tmppath)); + if (tmppath[0]) { + fprintf(stdout, "[\"page\",%d,%d,", page, total_pages); + write_json_string(stdout, tmppath); + fprintf(stdout, ",%d,%d,%d,%d]\n", w, h, page_width, page_height); + fflush(stdout); + } + } + + if (prev_rgb) free(prev_rgb); + prev_rgb = rgb; + prev_w = w; + prev_h = h; + prev_page = page; +} diff --git a/src/frontend/webview_output.h b/src/frontend/webview_output.h new file mode 100644 index 0000000..0d25e19 --- /dev/null +++ b/src/frontend/webview_output.h @@ -0,0 +1,15 @@ +#ifndef WEBVIEW_OUTPUT_H_ +#define WEBVIEW_OUTPUT_H_ + +#include "driver.h" +#include "engine.h" + +void webview_output_page(fz_context *ctx, txp_engine *eng, + int page, int total_pages, + int img_width, int img_height, + int page_width, int page_height, + const char *tmpdir, bool dark_mode); + +void webview_set_tmpdir(const char *dir); + +#endif diff --git a/tex-window-example.png b/tex-window-example.png new file mode 100644 index 0000000..47026ee Binary files /dev/null and b/tex-window-example.png differ diff --git a/texpresso-pr-summary.md b/texpresso-pr-summary.md new file mode 100644 index 0000000..165e059 --- /dev/null +++ b/texpresso-pr-summary.md @@ -0,0 +1,186 @@ +# TeXpresso VSCode Built-in Preview — PR Summary + +## Motivation + +TeXpresso originally only supported previewing LaTeX compilation results through an external SDL2 window. The goal is to enable built-in preview inside VSCode, achieving near-real-time incremental rendering comparable to the external SDL window experience. + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────────────┐ +│ VSCode Webview Panel (Canvas Viewer) │ +│ - base64 QOI data → JS decode → ImageBitmap → Canvas │ +│ - Zoom(Ctrl+wheel)/Pan/Nav/Dark Mode/i18n │ +│ - SyncTeX bidirectional: click→source / cursor→scroll │ +│ - Nav Bar: Home|◀|[Page]/Total|▶|End|↺|Res|[Options▾] │ +└──────────┬───────────────────────────────────────────────┘ + │ postMessage (base64 image data + control messages) +┌──────────▼───────────────────────────────────────────────┐ +│ Extension Host (TypeScript) │ +│ - preview.ts: WebviewPanel lifecycle │ +│ - extension.ts: command registration, message routing │ +│ - texpresso stdout JSON → intercept page messages → fwd │ +└──────────┬───────────────────────────────────────────────┘ + │ ① stdin/stdout JSON protocol (commands + sync) + │ ② Temp files (QOI image data, /dev/shm) +┌──────────▼───────────────────────────────────────────────┐ +│ texpresso C Backend (-webview mode) │ +│ - Skip SDL window, retain SDL event system │ +│ - MuPDF render → fz_pixmap → QOI → temp file → stdout │ +│ - Incremental: old vs new pixmap → dirty rects → diff │ +│ - New commands: synctex-backward, set-page, go-home, etc │ +└───────────────────────────────────────────────────────────┘ +``` + +## Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Image encoding | **QOI** | Already in codebase; 20-29× faster than PNG (~2.9ms vs ~83.8ms) | +| Transport | **Temp file + base64 postMessage** | VSCode postMessage only supports JSON; base64 is most efficient for binary | +| Temp file location | `/dev/shm/` | tmpfs avoids physical disk I/O | +| Webview frontend | **Custom Canvas viewer** | Fully controllable rendering pipeline | +| Mode coexistence | `-webview` CLI flag | Without flag: original SDL behavior unchanged | +| C-side auto-detect | `-resolution N` flag | Linked to VSCode setting `defaultResolution` | + +## Feature List + +### texpresso C Backend + +- **Webview output** (`webview_output.c`): MuPDF render → QOI encode → write to `/dev/shm` → stdout JSON with file path +- **Incremental rendering (dirty rects)**: Per-pixel comparison of old vs new pixmap → when changed area < 50%, transmit only changed rects as QOI → `page-diff` message +- **New commands**: `synctex-backward`, `set-page`, `set-output-size`, `go-home`, `go-end`, `reset-zoom`, `set-fit-mode`, `invert` +- **`-webview` mode**: Skip SDL window creation, only init `SDL_INIT_TIMER | SDL_INIT_EVENTS` +- **`-resolution N`**: Control first-render resolution multiplier, linked to VSCode settings +- **Polling engine advancement**: `go-end`/`set-page` continuously poll the engine subprocess until terminated or target page found (`step()` returns false during computation — keep polling) +- **Forward SyncTeX**: Output `synctex-scroll` message (TeX coordinates) to drive preview scrolling +- **Backward SyncTeX**: Receive preview click coordinates → `synctex_scan()` → output source file location + +### texpresso-vscode Extension + +- **Preview panel** (`preview.ts`): WebviewPanel lifecycle, QOI file read + base64 encode, message forwarding +- **Canvas viewer** (`webview/index.ts`): + - Pure-JS QOI decoder (~80 lines) + - LRU page cache (configurable 1-50 pages) + - Ctrl+wheel zoom (2.5% steps), Ctrl+click pan + - Inertia scrolling (H+V, configurable speed) + - Dark mode (CSS filter, persisted to localStorage) + - Forward SyncTeX: cursor moves → preview scrolls to match + - Backward SyncTeX: click preview → editor jumps to line + - Chinese/English i18n + - Configurable resolution (`defaultResolution`) + - Configurable scroll speed (`scrollSpeed`) + - Configurable max resolution cap (`maxResCap`) + - Page input with i18n error dialog + - One-click home/end/prev/next navigation (go-end supports cascading) +- **External window**: Retain original SDL window, Wayland/X11 env adaptation +- **Multi-file**: Sub-file → main-file mapping, bidirectional SyncTeX for `\include`/`\input` +- **Button visibility**: Built-in/external buttons mutually exclusive; SyncTeX buttons only visible during external preview + +### Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `texpresso.cacheSize` | 10 | Preview page cache size | +| `texpresso.maxResCap` | 10 | Max resolution multiplier | +| `texpresso.defaultResolution` | 2.5 | Default resolution multiplier | +| `texpresso.scrollSpeed` | 0.3 | Scroll speed coefficient | + +## File Changes + +### texpresso C Backend + +| File | Change | Description | +|------|--------|-------------| +| `src/frontend/webview_output.c` | **New** | QOI render output + dirty rect incremental diff | +| `src/frontend/webview_output.h` | **New** | Webview output interface declarations | +| `src/frontend/main.c` | Modified | Webview event loop, inline render, new command dispatch, polling advancement | +| `src/frontend/driver.c` | Modified | `-webview`/`-tmpdir`/`-resolution` flag parsing | +| `src/frontend/driver.h` | Modified | Webview-related state fields | +| `src/frontend/editor.c` | Modified | Parse 8 new commands | +| `src/frontend/editor.h` | Modified | New command tags and union fields | +| `src/frontend/renderer.c` | Modified | `invert_pixmap` made non-static, `render_to_pixmap` for offscreen rendering | +| `src/frontend/renderer.h` | Modified | Expose `invert_pixmap` and `render_to_pixmap` | + +### texpresso-vscode Extension + +| File | Change | Description | +|------|--------|-------------| +| `src/preview.ts` | **New** | PreviewPanel management class | +| `src/webview/index.ts` | **New** | Full Canvas viewer implementation | +| `src/extension.ts` | Modified | Command registration, multi-process, message routing, SyncTeX | +| `package.json` | Modified | New commands, buttons, settings, when conditions | +| `webpack.config.js` | Modified | Add webview webpack entry | + +## Technical Highlights + +### Incremental Rendering Pipeline + +``` +Source edit → texpresso incremental compile → MuPDF re-render current page + → New pixmap vs old pixmap per-pixel comparison + → Dirty area < 50%: each rect QOI encoded → page-diff message + → Dirty area ≥ 50%: full page QOI → page message +``` + +### Real-time Editing Latency Optimization + +- Keystroke changes → inline render (same iteration, no RELOAD_EVENT wait) +- RELOAD_EVENT deduplication guard (`webview_rendered_this_iteration` flag) +- Preloading phase does not trigger intermediate rendering + +### Engine Polling + +The TeX engine runs as a subprocess (texpresso-xetex) communicating via pipes. `step()` checks for pending queries with a 10μs timeout — returns false when the subprocess is computing but the engine is still `DOC_RUNNING`. Key loops (`go-end`, `set-page`) continuously poll rather than breaking on the first `false`. + +## Backward Compatibility + +- Without `-webview` flag, all SDL behavior is completely unchanged +- All existing commands (both S-expression and JSON protocol) are unaffected +- Existing VSCode commands have no breaking changes + +## Improvement Suggestions + +### Latency + +1. **First-frame render timing**: Page 0 is currently rendered via RELOAD_EVENT after `advance_engine` discovers it. For complex documents, consider synchronous rendering as soon as page 0 is discovered, bypassing the event loop delay. + +2. **Base64 overhead**: Encoding/decoding base64 for a full page QOI (~2.5MB @ 2.5x) takes ~6-8ms. VSCode's `postMessage` doesn't support ArrayBuffer transfer yet — if/when it does, this removes the 33% bloat and encoding cost. + +### Performance + +3. **Resolution-aware dirty rect detection**: Current per-pixel comparison runs at full resolution (e.g., 2.5x), scaling quadratically. Consider a quick low-resolution change check first, only upgrading to full resolution when changes are detected. + +4. **Web Worker QOI decoding**: QOI decoding for large images (full page @ 2.5x, 8-15ms) could move to a Web Worker to avoid blocking the main thread. + +5. **Canvas layering**: Page rendering and SyncTeX highlights share one canvas. Layering avoids full-page redraws. + +6. **LRU cache pre-warming**: Pre-decode adjacent pages' QOI data during idle time (without rendering, just caching ImageBitmaps) to accelerate page turn. + +### UX + +7. **Pinch-to-zoom**: Current zoom requires Ctrl+wheel. Adding `gesturechange` event support improves trackpad experience. + +8. **Smooth zoom animation**: Current zoom is instant. CSS transitions or Canvas inter-frame interpolation would make it feel smoother. + +9. **Dark mode CSS inversion for images**: `\includegraphics` content is distorted by CSS `invert(1) hue-rotate(180deg)`. Consider C-side color inversion via `txp_renderer_invert_pixmap` instead, or let users choose. + +10. **Mini-map**: For multi-page documents, a sidebar page thumbnail view would aid navigation. + +11. **Expose keyboard shortcuts**: Shortcuts are currently hardcoded in the webview. Exposing them via VSCode keybinding contributions would let users customize. + +### Robustness + +12. **Temp file cleanup**: texpresso cleans QOI temp files on clean exit, but crashes may leave orphans. Add a periodic cleanup for stale `/dev/shm/texpresso-*` files (>1 hour old). + +13. **Connection recovery**: When the texpresso process exits unexpectedly, show an error in the webview with a "reconnect" button instead of a silent black screen. + +14. **Large document memory management**: Monitor total `pageCache` size and evict entries under memory pressure. + +### Feature Extensions + +15. **Forward SyncTeX highlight**: Show a blinking cursor or highlight on the preview at the position corresponding to the source cursor. + +16. **Inline error display**: Overlay TeX compilation errors on the preview canvas at the relevant positions (like IDE red squiggles). + +17. **TikZ preview enhancement**: Provide separate thumbnail previews for TikZ pictures.