diff --git a/cmd/control.ts b/cmd/control.ts index 3140a61f54a..b3e5b6198ac 100644 --- a/cmd/control.ts +++ b/cmd/control.ts @@ -10,6 +10,7 @@ export enum ControlCommand { TestChm = 14, TestSelectionTranslate = 15, TestTripleClickLineSelect = 16, + TestInverseSearch = 22, } export type ControlArg = number | string | Uint8Array | ControlArg[]; diff --git a/src/AppTools.cpp b/src/AppTools.cpp index ad0357fc02f..af2b237cc08 100644 --- a/src/AppTools.cpp +++ b/src/AppTools.cpp @@ -421,8 +421,7 @@ char* BuildOpenFileCmd(const char* pattern, const char* path, int line, int col) perc++; if (*perc == 'f') { - char* fname = path::NormalizeTemp(path); - cmdline.Append(fname); + cmdline.Append(path); } else if (*perc == 'l') { cmdline.AppendFmt("%d", line); } else if (*perc == 'c') { diff --git a/src/PdfSync.cpp b/src/PdfSync.cpp index 7364dafcef5..c8bdf5320a1 100644 --- a/src/PdfSync.cpp +++ b/src/PdfSync.cpp @@ -43,7 +43,8 @@ struct PdfsyncPoint { // Synchronizer based on .pdfsync file generated with the pdfsync tex package class Pdfsync : public Synchronizer { public: - Pdfsync(const char* syncfilename, EngineBase* engine) : Synchronizer(syncfilename), engine(engine) { + Pdfsync(const char* syncfilename, const char* pdffilename, EngineBase* engine) + : Synchronizer(syncfilename, pdffilename), engine(engine) { ReportIf(!str::EndsWithI(syncfilename, ".pdfsync")); } @@ -65,7 +66,8 @@ class Pdfsync : public Synchronizer { // Synchronizer based on .synctex file generated with SyncTex class SyncTex : public Synchronizer { public: - SyncTex(const char* syncfilename, EngineBase* engineIn) : Synchronizer(syncfilename) { + SyncTex(const char* syncfilename, const char* pdffilename, EngineBase* engineIn) + : Synchronizer(syncfilename, pdffilename) { engine = engineIn; scanner = nullptr; ReportIf(!str::EndsWithI(syncfilename, ".synctex")); @@ -83,8 +85,9 @@ class SyncTex : public Synchronizer { synctex_scanner_p scanner; }; -Synchronizer::Synchronizer(const char* syncFilePathIn) { +Synchronizer::Synchronizer(const char* syncFilePathIn, const char* pdfPathIn) { syncFilePath = str::Dup(syncFilePathIn); + pdfPath = str::Dup(pdfPathIn); WCHAR* path = ToWStrTemp(syncFilePathIn); _wstat(path, &syncfileTimestamp); } @@ -136,7 +139,7 @@ int Synchronizer::Create(const char* path, EngineBase* engine, Synchronizer** sy // Check if a PDFSYNC file is present char* syncFile = str::JoinTemp(basePath, ".pdfsync"); if (file::Exists(syncFile)) { - *sync = new Pdfsync(syncFile, engine); + *sync = new Pdfsync(syncFile, path, engine); return *sync ? PDFSYNCERR_SUCCESS : PDFSYNCERR_OUTOFMEMORY; } @@ -147,7 +150,7 @@ int Synchronizer::Create(const char* path, EngineBase* engine, Synchronizer** sy if (file::Exists(texGzFile) || file::Exists(texFile)) { // due to a bug with synctex_parser.c, this must always be // the path to the .synctex file (even if a .synctex.gz file is used instead) - *sync = new SyncTex(texFile, engine); + *sync = new SyncTex(texFile, path, engine); return *sync ? PDFSYNCERR_SUCCESS : PDFSYNCERR_OUTOFMEMORY; } @@ -307,6 +310,19 @@ static int cmpLineRecords(const void* a, const void* b) { return ((PdfsyncLine*)a)->record - ((PdfsyncLine*)b)->record; } +// If `srcfilepath` doesn't exist on disk, checks whether it's been moved to sit +// next to the PDF document (which happens if all files are moved together) +static void TryRecoverMovedSourceFile(AutoFreeStr& srcfilepath, const char* pdfPath) { + if (file::Exists(srcfilepath)) { + return; + } + TempStr altsrcpath = path::GetDirTemp(pdfPath); + altsrcpath = path::JoinTemp(altsrcpath, path::GetBaseNameTemp(srcfilepath)); + if (!str::Eq(altsrcpath, srcfilepath) && file::Exists(altsrcpath)) { + srcfilepath.SetCopy(altsrcpath); + } +} + int Pdfsync::DocToSource(int pageNo, Point pt, AutoFreeStr& filename, int* line, int* col) { int res = RebuildIndexIfNeeded(); if (res != PDFSYNCERR_SUCCESS) { @@ -368,7 +384,9 @@ int Pdfsync::DocToSource(int pageNo, Point pt, AutoFreeStr& filename, int* line, } char* path = srcfiles[found->file]; - filename.SetCopy(path); + filename.SetCopy(path::NormalizeTemp(path)); + TryRecoverMovedSourceFile(filename, pdfPath); + *line = (int)found->line; *col = (int)found->column; if (*col < 0) { @@ -744,6 +762,59 @@ int SyncTex::RebuildIndexIfNeeded() { return MarkIndexWasRebuilt(); } +// Decides whether `resolvedSrcPath` should be treated as a Unix path rather than +// a Windows path. Returns true if the sync file itself lives on WSL, or if the +// resolved path is a WSl mount path (e.g. /mnt/c/...), which happens when the PDF +// lives on a Windows drive but was compiled from inside WSL. +static bool IsUnixSourcePath(const char* syncFilePath, const char* resolvedSrcPath) { + if (!syncFilePath || !resolvedSrcPath) { + return false; + } + + if (str::StartsWithI(syncFilePath, "\\\\wsl.localhost\\") || str::StartsWithI(syncFilePath, "\\\\wsl$\\")) { + return true; + } + + if (str::StartsWithI(resolvedSrcPath, "/mnt/") && std::isalpha((unsigned char)resolvedSrcPath[5]) && + (resolvedSrcPath[6] == '/' || resolvedSrcPath[6] == '\0')) { + return true; + } + + return false; +} + +// Converts a WSL UNC path to its equivalent Unix path by stripping the +// \\wsl.localhost\\ or \\wsl$\\ prefix. +// e.g. "\\wsl.localhost\Ubuntu\home\user\file.tex" -> "/home/user/file.tex" +static TempStr WslUncPathToUnixPathTemp(const char* srcfilepath) { + if (!srcfilepath) { + return nullptr; + } + + const char* p = nullptr; + + // Strip the WSL UNC prefix + if (str::StartsWithI(srcfilepath, "\\\\wsl.localhost\\")) { + p = srcfilepath + str::Len("\\\\wsl.localhost\\"); + } else if (str::StartsWithI(srcfilepath, "\\\\wsl$\\")) { + p = srcfilepath + str::Len("\\\\wsl$\\"); + } else { + return nullptr; + } + + // Skip the distribution name, e.g. "Ubuntu" in "\\wsl.localhost\Ubuntu\home\..." + while (*p && !path::IsSep(*p)) { + p++; + } + if (!path::IsSep(*p) || !p[1]) { + return nullptr; + } + + TempStr unixPath = str::JoinTemp("/", p + 1); + str::TransCharsInPlace(unixPath, "\\", "/"); + return unixPath; +} + int SyncTex::DocToSource(int pageNo, Point pt, AutoFreeStr& filename, int* line, int* col) { logfa("SyncTex::DocToSource: '%s', pageNo: %d\n", syncFilePath.Get(), pageNo); int res = RebuildIndexIfNeeded(); @@ -774,11 +845,28 @@ int SyncTex::DocToSource(int pageNo, Point pt, AutoFreeStr& filename, int* line, return PDFSYNCERR_OUTOFMEMORY; } - // undecorate the filepath: replace * by space and / by \ (backslash) - str::TransCharsInPlace(filename, "*/", " \\"); - // Convert the source filepath to an absolute path - if (!path::IsAbsolute(filename)) { - filename.Set(PrependDir(filename)); + // Unescape SyncTeX's space encoding: * represents a space in filenames + str::TransCharsInPlace(filename, "*", " "); + + if (IsUnixSourcePath(syncFilePath.Get(), filename)) { + // Treat filename as unix path + + // Resolve relative Unix paths relative to the sync file's directory + if (filename[0] != '/') { + TempStr unixSyncFilePath = WslUncPathToUnixPathTemp(syncFilePath.Get()); + TempStr dir = path::GetDirTemp(unixSyncFilePath); + filename.Set(path::Join(dir, filename)); + } + } else { + // Treat filename as Windows path + + str::TransCharsInPlace(filename, "/", "\\"); + // Convert the source filepath to an absolute path + if (!path::IsAbsolute(filename)) { + filename.Set(PrependDir(filename)); + } + filename.SetCopy(path::NormalizeTemp(filename.Get())); + TryRecoverMovedSourceFile(filename, pdfPath); } *line = synctex_node_line(node); @@ -790,6 +878,24 @@ int SyncTex::DocToSource(int pageNo, Point pt, AutoFreeStr& filename, int* line, return PDFSYNCERR_SUCCESS; } +// Converts a Windows absolute path to its WSL mount-path equivalent. +// e.g. "C:\project\file.tex" -> "/mnt/c/project/file.tex" +static TempStr WindowsPathToWslMountPathTemp(const char* path) { + if (!path) { + return nullptr; + } + + // Require an absolute Windows path (e.g. "C:\", or ""C:/"") + if (!(std::isalpha((unsigned char)path[0]) && path[1] == ':' && path::IsSep(path[2]))) { + return nullptr; + } + + char drive = (char)tolower((unsigned char)path[0]); + TempStr rest = str::DupTemp(path + 3); // Skip "[drive]:\" + str::TransCharsInPlace(rest, "\\", "/"); + return str::FormatTemp("/mnt/%c/%s", drive, rest); +} + int SyncTex::SourceToDoc(const char* srcfilename, int line, int col, int* page, Vec& rects) { logfa("SyncTex::SourceToDoc: '%s', line: %d, col: %d\n", srcfilename, line, col); int res = RebuildIndexIfNeeded(); @@ -809,9 +915,23 @@ int SyncTex::SourceToDoc(const char* srcfilename, int line, int col, int* page, return PDFSYNCERR_OUTOFMEMORY; } + if (TempStr unixSrcFilePath = WslUncPathToUnixPathTemp(srcfilepath)) { + srcfilepath = unixSrcFilePath; + } + // dealed in SyncTex::RebuildIndexIfNeeded() int ret = synctex_display_query(this->scanner, srcfilepath, line, col, 0); + if (ret <= 0) { + if (TempStr wslMountSrcFilePath = WindowsPathToWslMountPathTemp(srcfilepath)) { + srcfilepath = wslMountSrcFilePath; + logfa("SyncTex::SourceToDoc: retrying with WSL mount path '%s'\n", srcfilepath); + int ret2 = synctex_display_query(this->scanner, srcfilepath, line, col, 0); + if (ret2 > 0) { + ret = ret2; + } + } + } if (-1 == ret) { return PDFSYNCERR_UNKNOWN_SOURCEFILE; } diff --git a/src/PdfSync.h b/src/PdfSync.h index cc9c67ab70a..0c83dbdc0cb 100644 --- a/src/PdfSync.h +++ b/src/PdfSync.h @@ -22,7 +22,7 @@ class EngineBase; class Synchronizer { public: - explicit Synchronizer(const char* syncfilepath); + explicit Synchronizer(const char* syncfilepath, const char* pdffilename); virtual ~Synchronizer() = default; // Inverse-search: @@ -51,6 +51,7 @@ class Synchronizer { char* PrependDir(const char* filename) const; AutoFreeStr syncFilePath; // path to the synchronization file + AutoFreeStr pdfPath; public: static int Create(const char* pdffilename, EngineBase* engine, Synchronizer** sync); diff --git a/src/SearchAndDDE.cpp b/src/SearchAndDDE.cpp index 05796fd516f..90ecf19af76 100644 --- a/src/SearchAndDDE.cpp +++ b/src/SearchAndDDE.cpp @@ -583,16 +583,6 @@ bool OnInverseSearch(MainWindow* win, int x, int y) { return true; } - if (!file::Exists(srcfilepath)) { - // if the source file is missing, check if it's been moved to the same place as - // the PDF document (which happens if all files are moved together) - TempStr altsrcpath = path::GetDirTemp(tab->filePath); - altsrcpath = path::JoinTemp(altsrcpath, path::GetBaseNameTemp(srcfilepath)); - if (!str::Eq(altsrcpath, srcfilepath) && file::Exists(altsrcpath)) { - srcfilepath.SetCopy(altsrcpath); - } - } - char* inverseSearch = gGlobalPrefs->inverseSearchCmdLine; if (!inverseSearch) { Vec editors; diff --git a/src/SumatraControl.cpp b/src/SumatraControl.cpp index 68a62c26779..8e015669d35 100644 --- a/src/SumatraControl.cpp +++ b/src/SumatraControl.cpp @@ -28,6 +28,7 @@ enum class ControlCmd : u16 { TestChm = 14, TestSelectionTranslate = 15, TestTripleClickLineSelect = 16, + TestInverseSearch = 22, }; enum class ControlArgType : u16 { @@ -291,6 +292,17 @@ static void ExecuteControlRequest(ControlRequest* req) { break; } + case ControlCmd::TestInverseSearch: { + i32 page = 0, x = 0, y = 0; + const char* pdf = StringArg(req, 0); + if (!pdf || !IntArg(req, 1, page) || !IntArg(req, 2, x) || !IntArg(req, 3, y)) { + AppendError(req, "TestInverseSearch expects string pdf, int page, int x, int y"); + break; + } + AppendTestResult(req, 0, TestInverseSearchResult(pdf, page, x, y)); + break; + } + case ControlCmd::TestSearch: { const char* pdf = StringArg(req, 0); const char* needle = StringArg(req, 1); diff --git a/src/SumatraTest.cpp b/src/SumatraTest.cpp index 5d7ad6dcb15..159dade75c0 100644 --- a/src/SumatraTest.cpp +++ b/src/SumatraTest.cpp @@ -54,7 +54,45 @@ char* TestSynctexResult(const char* pdfPath, const char* srcPath, int line) { int page = 0; Vec rects; int ret = sync->SourceToDoc(srcPath, line, 0, &page, rects); - out.AppendFmt("ret=%d page=%d nrects=%d src=%s line=%d\n", ret, page, rects.Size(), srcPath, line); + out.AppendFmt("ret=%d page=%d nrects=%d src=%s line=%d", ret, page, rects.Size(), srcPath, line); + if (rects.Size() > 0) { + Rect r = rects.at(0); + out.AppendFmt(" rect_x=%d rect_y=%d rect_dx=%d rect_dy=%d\n", r.x, r.y, r.dx, r.dy); + } + delete sync; + } + SafeEngineRelease(&engine); + } + + return out.StealData(); +} + +// Headless inverse-search test for issue #5702. Loads the pdf, creates a +// Synchronizer, and resolves (page, point) -> (srcfile, line, col) via +// DocToSource, returning a machine-readable result line. +char* TestInverseSearchResult(const char* pdfPath, int pageNo, int x, int y) { + ScopedGdiPlus gdiPlus; + EnsureTestGlobalPrefs(); + + StrBuilder out; + EngineBase* engine = CreateEngineFromFile(pdfPath, nullptr, false); + if (!engine) { + out.AppendFmt("ERROR engine-create-failed pdf=%s\n", pdfPath); + } else { + Synchronizer* sync = nullptr; + int err = Synchronizer::Create(pdfPath, engine, &sync); + if (err != PDFSYNCERR_SUCCESS || !sync) { + out.AppendFmt("ERROR sync-create-failed err=%d\n", err); + } else { + AutoFreeStr srcfilepath; + int line = 0, col = 0; + Point pt(x, y); + int ret = sync->DocToSource(pageNo, pt, srcfilepath, &line, &col); + if (ret != PDFSYNCERR_SUCCESS) { + out.AppendFmt("ERROR doctosource-failed err=%d\n", ret); + } else { + out.AppendFmt("ret=%d srcfile=%s line=%d col=%d\n", ret, srcfilepath.Get(), line, col); + } delete sync; } SafeEngineRelease(&engine); diff --git a/src/SumatraTest.h b/src/SumatraTest.h index 81a011d2c4a..790544bc27b 100644 --- a/src/SumatraTest.h +++ b/src/SumatraTest.h @@ -2,6 +2,7 @@ License: GPLv3 */ char* TestSynctexResult(const char* pdfPath, const char* srcPath, int line); +char* TestInverseSearchResult(const char* pdfPath, int pageNo, int x, int y); char* TestSearchResult(const char* pdfPath, const char* needle, const char* password = nullptr); char* TestDestResult(const char* pdfPath, int destNo); char* TestNamedDestResult(const char* pdfPath, const char* destName); diff --git a/tests/all.ts b/tests/all.ts index c592cd486ff..eaf7cc9f3b9 100644 --- a/tests/all.ts +++ b/tests/all.ts @@ -24,6 +24,7 @@ import { testit as issue5642 } from "./issue-5642.ts"; import { testit as issue5665 } from "./issue-5665.ts"; import { testit as issue5677 } from "./issue-5677.ts"; import { testit as issue5681 } from "./issue-5681.ts"; +import { testit as issue5702 } from "./issue-5702.ts"; const tests: [string, () => void | Promise][] = [ ["cmd-start-autoscroll", cmdStartAutoScroll], @@ -40,6 +41,7 @@ const tests: [string, () => void | Promise][] = [ ["issue-5665", issue5665], ["issue-5677", issue5677], ["issue-5681", issue5681], + ["issue-5702", issue5702], ]; export type AllTestOptions = { diff --git a/tests/issue-5702-data/.gitignore b/tests/issue-5702-data/.gitignore new file mode 100644 index 00000000000..fa14b6f9c82 --- /dev/null +++ b/tests/issue-5702-data/.gitignore @@ -0,0 +1,2 @@ +# scratch dir created at runtime by tests/issue-5702.ts for tectonic output and test results +.work/ diff --git a/tests/issue-5702-data/test.tex b/tests/issue-5702-data/test.tex new file mode 100644 index 00000000000..f39175085d7 --- /dev/null +++ b/tests/issue-5702-data/test.tex @@ -0,0 +1,11 @@ +\documentclass{article} +\begin{document} + +First paragraph of text on line four. It contains enough words that the +typesetter places it into a paragraph and synctex records a position that +maps back to this source line. + +Second paragraph of text on line eight, also with plenty of words so that +synctex emits boxes that can be mapped back to this exact source line. + +\end{document} diff --git a/tests/issue-5702.ts b/tests/issue-5702.ts new file mode 100644 index 00000000000..a24ce37a811 --- /dev/null +++ b/tests/issue-5702.ts @@ -0,0 +1,305 @@ +// Test for https://github.com/sumatrapdfreader/sumatrapdf/issues/5702 +// +// Verifies that SyncTeX forward and inverse search work across two +// previously-unsupported WSL workflows: +// 1. files on the WSL filesystem, compiled from inside WSL +// 2. files on the Windows filesystem, compiled from inside WSL +// (e.g. via `wsl.exe -d Ubuntu -- tectonic ...` against /mnt/c/...) +// +// Uses Tectonic (run via WSL) to produce a real PDF + .synctex.gz pair for +// each workflow, then runs the control pipe TestSynctex/TestInverseSearch +// commands, which query SourceToDoc/DocToSource directly and check the +// results. +// +// Run: bun tests/issue-5702.ts [--no-build] (or via tests/all.ts) + +import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EXE, runStandalone } from "./util.ts"; +import { ControlClient, ControlCommand, withControlledSumatra } from "../cmd/control.ts"; + +const DATA = join(import.meta.dir, "issue-5702-data"); +const WORK = join(DATA, ".work"); + +const TEX_NAME = "test.tex"; +const TEX_SRC = join(DATA, TEX_NAME); +const PDF_NAME = TEX_NAME.replace(/\.tex$/, ".pdf"); +const SYNCTEX_NAME = TEX_NAME.replace(/\.tex$/, ".synctex.gz"); + +const WSL_DISTRO = "Ubuntu"; +const WSL_UNC_ROOT = `\\\\wsl.localhost\\${WSL_DISTRO}`; +const WSL_TEST_DIR_UNIX = "/tmp/sumatra-test-5702"; +const WSL_TEST_DIR_UNC = join(WSL_UNC_ROOT, "tmp", "sumatra-test-5702"); + +// line 4 of issue-5702-data/test.tex is a body paragraph that synctex maps to a position +const TARGET_LINE = 4; + +type Rect = { + x: number, + y: number, + dx: number, + dy: number, +} + +type FwdSearchResult = { + ret: number; + page: number; + nrects: number; + rect?: Rect, + raw: string; +}; + +type FwdSearchPoint = { page: number; x: number; y: number }; + +type InvSearchResult = { + ret: number; + srcfile: string; + line: number; + col: number; + raw: string; +}; + + +function run(cmd: string[]): { ok: boolean; stdout: string; stderr: string } { + const p = Bun.spawnSync({ cmd, stdout: "pipe", stderr: "pipe" }); + return { + ok: p.exitCode === 0, + stdout: p.stdout.toString(), + stderr: p.stderr.toString(), + }; +} + +function findWslTectonic(): boolean { + const r = run(["wsl.exe", "-d", WSL_DISTRO, "--", "tectonic", "--version"]); + if (!r.ok) { + console.log( + `\nSKIP issue-5702: WSL distro '${WSL_DISTRO}' with tectonic not available, skipping.\n` + + `To run this test, install a WSL distro and tectonic inside it, e.g.:\n` + + ` wsl --install -d ${WSL_DISTRO}\n` + + ` wsl -d ${WSL_DISTRO} -- bash -lc "curl --proto '=https' --tlsv1.2 -fsSL https://drop-sh.fullyjustified.net |sh"`, + ); + } + return r.ok; +} + +// converts a Windows absolute path (e.g. "C:\foo\bar.tex") to its WSL +// mount-path equivalent (e.g. "/mnt/c/foo/bar.tex"), for invoking tectonic +// from inside WSL and for computing expected DocToSource results. +function windowsPathToWslMountPath(winPath: string): string { + const m = winPath.match(/^([A-Za-z]):[\\/](.*)$/); + if (!m) { + throw new Error(`not a Windows absolute path: ${winPath}`); + } + const drive = m[1].toLowerCase(); + const rest = m[2].replace(/\\/g, "/"); + return `/mnt/${drive}/${rest}`; +} + +// runs the app's control-pipe synctex forward-search for the given pdf and +// returns the parsed result (ret is a PDFSYNCERR_* code; 0 == PDFSYNCERR_SUCCESS), +// including the first rect's coordinates. +async function forwardSearch( + client: ControlClient, + pdfPath: string, + srcPath: string, +): Promise { + const [, rawArg] = await client.request(ControlCommand.TestSynctex, [pdfPath, srcPath, TARGET_LINE]); + const raw = String(rawArg).trim(); + + const m = raw.match(/ret=(-?\d+)\s+page=(-?\d+)\s+nrects=(-?\d+)/); + if (!m) throw Error(`(forwardSearch: parse error) raw: ${raw}`); + const result: FwdSearchResult = { + ret: parseInt(m[1]), + page: parseInt(m[2]), + nrects: parseInt(m[3]), + raw, + }; + const n = raw.match( + /rect_x=(-?\d+)\s+rect_y=(-?\d+)\s+rect_dx=(-?\d+)\s+rect_dy=(-?\d+)/, + ); + if (n) { + result["rect"] = { + x: parseInt(n[1]), + y: parseInt(n[2]), + dx: parseInt(n[3]), + dy: parseInt(n[4]), + }; + } + + return result; +} + +// runs the app's control-pipe synctex inverse-search for the given pdf and +// returns the parsed result (ret is a PDFSYNCERR_* code; 0 == PDFSYNCERR_SUCCESS) +async function inverseSearch( + client: ControlClient, + pdfPath: string, + page: number, + x: number, + y: number, +): Promise { + const [, rawArg] = await client.request(ControlCommand.TestInverseSearch, [pdfPath, page, x, y]); + const raw = String(rawArg).trim(); + + const m = raw.match(/ret=(-?\d+)\s+srcfile=(.*)\s+line=(-?\d+)\s+col=(-?\d+)/); + if (!m) { + throw Error(`(inverseSearch: parse error) raw: ${raw}`); + } + return { ret: parseInt(m[1]), srcfile: m[2], line: parseInt(m[3]), col: parseInt(m[4]), raw }; +} + +// derives a (page, x, y) point from a forward-search result, taking the +// center of the rect TARGET_LINE mapped to. Returns null if the result +// wasn't a usable match (e.g. the forward search itself failed). +function pointFromFwdSearchResult(res: FwdSearchResult): FwdSearchPoint | null { + if (res.ret !== 0 || res.page < 1 || res.nrects < 1 || !res.rect) { + return null; + } + return { + page: res.page, + x: Math.round(res.rect.x + res.rect.dx / 2), + y: Math.round(res.rect.y + res.rect.dy / 2), + }; +} + +// verifies a tectonic compile produced both the pdf and synctex.gz; throws +// with captured stdout/stderr otherwise. +function verifyCompileOutput( + compile: { ok: boolean; stdout: string; stderr: string }, + pdfPath: string, + synctexPath: string, + label: "Windows file" | "WSL file", +): void { + if (!compile.ok || !existsSync(pdfPath) || !existsSync(synctexPath)) { + console.error(compile.stdout); + console.error(compile.stderr); + throw new Error(`tectonic did not produce ${pdfPath} + ${synctexPath} (${label})`); + } +} + +// compiles issue-5702-data/test.tex on the Windows filesystem via +// `wsl.exe -d Ubuntu -- tectonic ...` (workflow: Windows files, WSL compile). +// Returns the Windows paths to the resulting pdf and source file. +function compileWinFiles(): { pdfPath: string; srcPath: string } { + rmSync(WORK, { recursive: true, force: true }); + mkdirSync(WORK, { recursive: true }); + + const wslTexPath = windowsPathToWslMountPath(TEX_SRC); + const wslOutDir = windowsPathToWslMountPath(WORK); + + console.log(`• compiling ${TEX_SRC} via wsl -d ${WSL_DISTRO} -- tectonic ...`); + const compile = run([ + "wsl.exe", "-d", WSL_DISTRO, "--", + "tectonic", "-X", "compile", wslTexPath, "--synctex", "--outdir", wslOutDir, + ]); + + const pdfPath = join(WORK, PDF_NAME); + const synctexPath = join(WORK, SYNCTEX_NAME); + verifyCompileOutput(compile, pdfPath, synctexPath, "Windows file"); + + return { pdfPath, srcPath: TEX_SRC }; +} + +// copies issue-5702-data/test.tex onto the WSL filesystem (via its UNC path) +// and compiles it there (workflow: WSL files, WSL compile). Returns the WSL +// UNC paths to the resulting pdf and source file. +function compileWslFiles(): { pdfPath: string; srcPath: string } { + rmSync(WSL_TEST_DIR_UNC, { recursive: true, force: true }); + mkdirSync(WSL_TEST_DIR_UNC, { recursive: true }); + + const texContent = readFileSync(TEX_SRC); + const srcPath = join(WSL_TEST_DIR_UNC, TEX_NAME); + writeFileSync(srcPath, texContent); + + console.log(`• compiling ${WSL_TEST_DIR_UNIX}/${TEX_NAME} via wsl -d ${WSL_DISTRO} -- tectonic ...`); + const compile = run([ + "wsl.exe", "-d", WSL_DISTRO, "--", + "tectonic", "-X", "compile", `${WSL_TEST_DIR_UNIX}/${TEX_NAME}`, "--synctex", + ]); + + const pdfPath = join(WSL_TEST_DIR_UNC, PDF_NAME); + const synctexPath = join(WSL_TEST_DIR_UNC, SYNCTEX_NAME); + verifyCompileOutput(compile, pdfPath, synctexPath, "WSL file"); + + return { pdfPath, srcPath }; +} + +async function testForwardSearch( + client: ControlClient, + pdfPath: string, + srcPath: string, + label: "Windows file" | "WSL file", +): Promise<{ ok: boolean, result: FwdSearchResult }> { + const res = await forwardSearch(client, pdfPath, srcPath); + const pass = res.ret === 0 && res.page >= 1 && res.nrects >= 1; + console.log(`${pass ? "PASS" : "FAIL"} forward search (${label}) -> ${res.raw}`); + return { ok: pass, result: res }; +} + +async function testInverseSearch( + client: ControlClient, + pdfPath: string, + srcPath: string, + fwdResult: FwdSearchResult, + label: "Windows file" | "WSL file", +): Promise<{ ok: boolean }> { + const pt = pointFromFwdSearchResult(fwdResult); + if (!pt) { + console.log(`FAIL inverse search (${label}) -> could not discover point: ${fwdResult.raw}`); + return { ok: false }; + } + const expectedSrcFile = label === "Windows file" ? windowsPathToWslMountPath(srcPath) : `${WSL_TEST_DIR_UNIX}/${TEX_NAME}`; + const res = await inverseSearch(client, pdfPath, pt.page, pt.x, pt.y); + const pass = res.ret === 0 && res.srcfile === expectedSrcFile && res.line === TARGET_LINE; + console.log(`${pass ? "PASS" : "FAIL"} inverse search (${label}) -> ${res.raw}`); + return { ok: pass }; +} + +// --------------------------------------------------------------------------- + +export async function testit(): Promise { + if (!existsSync(EXE)) { + throw new Error(`app not found: ${EXE} (build first)`); + } + if (!existsSync(TEX_SRC)) { + throw new Error(`missing test fixture: ${TEX_SRC}`); + } + if (!findWslTectonic()) { + return; + } + + // compile the fixture for Windows and WSL before running any test + const winFiles = compileWinFiles(); + const wslFiles = compileWslFiles(); + + const results = await withControlledSumatra(EXE, async (client) => { + const fwdWin = await testForwardSearch(client, winFiles.pdfPath, winFiles.srcPath, "Windows file"); + const fwdWsl = await testForwardSearch(client, wslFiles.pdfPath, wslFiles.srcPath, "WSL file"); + const invWin = await testInverseSearch(client, winFiles.pdfPath, winFiles.srcPath, fwdWin.result, "Windows file"); + const invWsl = await testInverseSearch(client, wslFiles.pdfPath, wslFiles.srcPath, fwdWsl.result, "WSL file"); + + // forward search on forward-slash Windows path + const fwdWin_fwdslash = await testForwardSearch( + client, winFiles.pdfPath, winFiles.srcPath.replace(/\\/g, "/"), "Windows file"); + + return [ + { name: "forward search(win files)", ok: fwdWin.ok }, + { name: "forward search(wsl files)", ok: fwdWsl.ok }, + { name: "inverse search(win files)", ok: invWin.ok }, + { name: "inverse search(wsl files)", ok: invWsl.ok }, + { name: "forward search(forward-slash win path)", ok: fwdWin_fwdslash.ok }, + ]; + }); + + const failed = results.filter((r) => !r.ok); + console.log(""); + if (failed.length > 0) { + throw new Error(`${failed.length}/${results.length} tests failed: ${failed.map((f) => f.name).join(", ")}`); + } + + console.log(`PASS issue-5702: all ${results.length} WSL synctex scenarios resolved correctly`); +} + +if (import.meta.main) { + await runStandalone(testit); +}