mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:59:27 +08:00
This commit is contained in:
1 parent
66f0d8a928
commit
2ea937b7be
11 files changed
+640
-18
No files matched your search
+16
-2
@@ -22,13 +22,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-space"></div>
|
<div class="toolbar-space"></div>
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>‹</button>
|
<button id="prevButton" class="icon-button arrow-prev" type="button" title="上一页" aria-label="上一页" disabled></button>
|
||||||
<label class="page-control">
|
<label class="page-control">
|
||||||
<input id="pageInput" type="number" min="1" value="0" disabled>
|
<input id="pageInput" type="number" min="1" value="0" disabled>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span id="pageTotal">0</span>
|
<span id="pageTotal">0</span>
|
||||||
</label>
|
</label>
|
||||||
<button id="nextButton" class="icon-button" type="button" title="下一页" disabled>›</button>
|
<button id="nextButton" class="icon-button arrow-next" type="button" title="下一页" aria-label="下一页" disabled></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
|
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
|
||||||
@@ -58,9 +58,23 @@
|
|||||||
<div id="navigationTabs" class="navigation-tabs" role="tablist" aria-label="文档导航" hidden>
|
<div id="navigationTabs" class="navigation-tabs" role="tablist" aria-label="文档导航" hidden>
|
||||||
<button id="pagesTab" type="button" role="tab" aria-selected="true" aria-controls="pageList">页面</button>
|
<button id="pagesTab" type="button" role="tab" aria-selected="true" aria-controls="pageList">页面</button>
|
||||||
<button id="outlinesTab" type="button" role="tab" aria-selected="false" aria-controls="outlineList" tabindex="-1">目录</button>
|
<button id="outlinesTab" type="button" role="tab" aria-selected="false" aria-controls="outlineList" tabindex="-1">目录</button>
|
||||||
|
<button id="searchTab" type="button" role="tab" aria-selected="false" aria-controls="searchPanel" tabindex="-1">搜索</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="pageList" class="page-list" role="tabpanel" aria-label="页面"></div>
|
<div id="pageList" class="page-list" role="tabpanel" aria-label="页面"></div>
|
||||||
<div id="outlineList" role="tabpanel" aria-label="目录" hidden></div>
|
<div id="outlineList" role="tabpanel" aria-label="目录" hidden></div>
|
||||||
|
<div id="searchPanel" role="tabpanel" aria-label="搜索" hidden>
|
||||||
|
<form id="searchForm" class="search-form" role="search">
|
||||||
|
<input id="searchInput" type="search" aria-label="搜索文字" placeholder="搜索文字" autocomplete="off">
|
||||||
|
<button type="submit" class="button">搜索</button>
|
||||||
|
</form>
|
||||||
|
<div class="search-tools">
|
||||||
|
<span id="searchStatus" role="status" aria-live="polite">输入文字</span>
|
||||||
|
<span id="searchCount">0 / 0</span>
|
||||||
|
<button id="searchPrev" type="button" class="icon-button arrow-prev" title="上一处" aria-label="上一处" disabled></button>
|
||||||
|
<button id="searchNext" type="button" class="icon-button arrow-next" title="下一处" aria-label="下一处" disabled></button>
|
||||||
|
</div>
|
||||||
|
<div id="searchResults" class="search-results"></div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="viewer-panel" tabindex="0">
|
<section class="viewer-panel" tabindex="0">
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ button:not(:disabled):hover {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-prev::before,
|
||||||
|
.arrow-next::before {
|
||||||
|
width: 5px;
|
||||||
|
height: 8px;
|
||||||
|
background: currentColor;
|
||||||
|
clip-path: polygon(100% 0, 100% 20%, 30% 50%, 100% 80%, 100% 100%, 0 50%);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-next::before {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.open-icon {
|
.open-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -287,6 +300,7 @@ body[data-hide-meta] .meta-panel {
|
|||||||
|
|
||||||
.page-list-panel,
|
.page-list-panel,
|
||||||
.viewer-panel,
|
.viewer-panel,
|
||||||
|
.search-results,
|
||||||
.meta-panel {
|
.meta-panel {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--line-strong) transparent;
|
scrollbar-color: var(--line-strong) transparent;
|
||||||
@@ -400,6 +414,110 @@ body[data-hide-meta] .meta-panel {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body:not([data-hide-pages]) .page-list-panel:has(#searchPanel:not([hidden])),
|
||||||
|
#searchPanel:not([hidden]) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchPanel {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchPanel :focus-visible {
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form input {
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2428;
|
||||||
|
font-size: 13px;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 8px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
#searchStatus {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tools button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result[aria-current="true"] {
|
||||||
|
background: #e4f3ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-page {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result mark {
|
||||||
|
background: #c9e6de;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(15, 123, 104, 0.24);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.outline-list .outline-list {
|
.outline-list .outline-list {
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
@@ -1174,6 +1292,7 @@ button.outline-link:hover {
|
|||||||
.workspace {
|
.workspace {
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-hide-pages] .workspace,
|
body[data-hide-pages] .workspace,
|
||||||
|
|||||||
+224
-15
@@ -32,6 +32,10 @@ const state = {
|
|||||||
openSeq: 0,
|
openSeq: 0,
|
||||||
pageSeq: 0,
|
pageSeq: 0,
|
||||||
fontSeq: 0,
|
fontSeq: 0,
|
||||||
|
searchSeq: 0,
|
||||||
|
searchQuery: "",
|
||||||
|
searchMatches: [],
|
||||||
|
searchIndex: -1,
|
||||||
localFonts: [],
|
localFonts: [],
|
||||||
userFonts: [],
|
userFonts: [],
|
||||||
fontSyncPending: false,
|
fontSyncPending: false,
|
||||||
@@ -99,6 +103,15 @@ const el = {
|
|||||||
outlinesTab: document.querySelector("#outlinesTab"),
|
outlinesTab: document.querySelector("#outlinesTab"),
|
||||||
pageList: document.querySelector("#pageList"),
|
pageList: document.querySelector("#pageList"),
|
||||||
outlineList: document.querySelector("#outlineList"),
|
outlineList: document.querySelector("#outlineList"),
|
||||||
|
searchTab: document.querySelector("#searchTab"),
|
||||||
|
searchPanel: document.querySelector("#searchPanel"),
|
||||||
|
searchForm: document.querySelector("#searchForm"),
|
||||||
|
searchInput: document.querySelector("#searchInput"),
|
||||||
|
searchStatus: document.querySelector("#searchStatus"),
|
||||||
|
searchCount: document.querySelector("#searchCount"),
|
||||||
|
searchPrev: document.querySelector("#searchPrev"),
|
||||||
|
searchNext: document.querySelector("#searchNext"),
|
||||||
|
searchResults: document.querySelector("#searchResults"),
|
||||||
metaPanel: document.querySelector(".meta-panel"),
|
metaPanel: document.querySelector(".meta-panel"),
|
||||||
appPanel: document.querySelector("#appPanel"),
|
appPanel: document.querySelector("#appPanel"),
|
||||||
offlineStatus: document.querySelector("#offlineStatus"),
|
offlineStatus: document.querySelector("#offlineStatus"),
|
||||||
@@ -128,14 +141,43 @@ const el = {
|
|||||||
el.ofdButton.addEventListener("click", openOFDFile);
|
el.ofdButton.addEventListener("click", openOFDFile);
|
||||||
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
||||||
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
||||||
el.pagesTab.addEventListener("click", () => showOutlines(false));
|
el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab));
|
||||||
el.outlinesTab.addEventListener("click", () => showOutlines(true));
|
el.outlinesTab.addEventListener("click", () => showNavigation(el.outlinesTab));
|
||||||
|
el.searchTab.addEventListener("click", () => {
|
||||||
|
showNavigation(el.searchTab);
|
||||||
|
el.searchInput.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
el.navigationTabs.addEventListener("keydown", (event) => {
|
el.navigationTabs.addEventListener("keydown", (event) => {
|
||||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const outlines = event.key === "End" || (event.key !== "Home" && el.outlineList.hidden);
|
const tabs = [el.pagesTab, el.outlinesTab, el.searchTab].filter((tab) => !tab.hidden);
|
||||||
showOutlines(outlines);
|
const current = tabs.indexOf(event.target);
|
||||||
(outlines ? el.outlinesTab : el.pagesTab).focus();
|
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
|
||||||
|
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||||
|
showNavigation(tabs[index]);
|
||||||
|
tabs[index].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.searchForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
searchDocument();
|
||||||
|
});
|
||||||
|
el.searchInput.addEventListener("input", () => resetSearch(false));
|
||||||
|
el.searchPrev.addEventListener("click", () => selectSearchMatch(Math.max(0, state.searchIndex) - 1));
|
||||||
|
el.searchNext.addEventListener("click", () => selectSearchMatch(state.searchIndex + 1));
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && state.doc) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!state.showPages) {
|
||||||
|
toggleSidebar("pages");
|
||||||
|
}
|
||||||
|
showNavigation(el.searchTab);
|
||||||
|
el.searchInput.focus({ preventScroll: true });
|
||||||
|
el.searchInput.select();
|
||||||
|
} else if (event.key === "Escape" && !el.searchPanel.hidden) {
|
||||||
|
resetSearch();
|
||||||
|
showNavigation(el.pagesTab);
|
||||||
|
el.pagesTab.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
|
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
|
||||||
@@ -1145,6 +1187,7 @@ async function openDocument(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resetLocalFonts = !options.skipAutoFonts;
|
const resetLocalFonts = !options.skipAutoFonts;
|
||||||
|
resetSearch();
|
||||||
if (resetLocalFonts) {
|
if (resetLocalFonts) {
|
||||||
state.localFonts = [];
|
state.localFonts = [];
|
||||||
updateFontSummary();
|
updateFontSummary();
|
||||||
@@ -1619,6 +1662,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
|
|||||||
surface.append(anchor);
|
surface.append(anchor);
|
||||||
}
|
}
|
||||||
shell.classList.add("rendered");
|
shell.classList.add("rendered");
|
||||||
|
renderSearchHighlights(index);
|
||||||
if (state.doc?.pages?.[index]) {
|
if (state.doc?.pages?.[index]) {
|
||||||
layoutPageShell(shell, state.doc.pages[index]);
|
layoutPageShell(shell, state.doc.pages[index]);
|
||||||
}
|
}
|
||||||
@@ -1823,25 +1867,190 @@ function prefixSVGIds(svg, prefix) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOutlines(show) {
|
function showNavigation(selected) {
|
||||||
el.pageList.hidden = show;
|
for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.searchTab, el.searchPanel]]) {
|
||||||
el.outlineList.hidden = !show;
|
const active = tab === selected;
|
||||||
el.pagesTab.setAttribute("aria-selected", String(!show));
|
panel.hidden = !active;
|
||||||
el.outlinesTab.setAttribute("aria-selected", String(show));
|
tab.setAttribute("aria-selected", String(active));
|
||||||
el.pagesTab.tabIndex = show ? -1 : 0;
|
tab.tabIndex = active ? 0 : -1;
|
||||||
el.outlinesTab.tabIndex = show ? 0 : -1;
|
}
|
||||||
el.pageListPanel.scrollTop = 0;
|
el.pageListPanel.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOutlines() {
|
function renderOutlines() {
|
||||||
const outlines = state.doc.outlines || [];
|
const outlines = state.doc.outlines || [];
|
||||||
el.pageListTitle.hidden = outlines.length > 0;
|
el.pageListTitle.hidden = true;
|
||||||
el.navigationTabs.hidden = outlines.length === 0;
|
el.navigationTabs.hidden = false;
|
||||||
|
el.outlinesTab.hidden = outlines.length === 0;
|
||||||
el.outlineList.replaceChildren();
|
el.outlineList.replaceChildren();
|
||||||
if (outlines.length > 0) {
|
if (outlines.length > 0) {
|
||||||
el.outlineList.append(createOutlineList(outlines));
|
el.outlineList.append(createOutlineList(outlines));
|
||||||
}
|
}
|
||||||
showOutlines(false);
|
showNavigation(el.pagesTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch(clearInput = true) {
|
||||||
|
state.searchSeq += 1;
|
||||||
|
state.searchQuery = "";
|
||||||
|
state.searchMatches = [];
|
||||||
|
state.searchIndex = -1;
|
||||||
|
if (clearInput) {
|
||||||
|
el.searchInput.value = "";
|
||||||
|
}
|
||||||
|
el.searchStatus.textContent = "输入文字";
|
||||||
|
el.searchResults.replaceChildren();
|
||||||
|
updateSearchCount();
|
||||||
|
clearSearchHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchDocument() {
|
||||||
|
if (!state.doc || document.body.hasAttribute("aria-busy")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = el.searchInput.value.trim();
|
||||||
|
if (query && query === state.searchQuery) {
|
||||||
|
await selectSearchMatch(state.searchIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetSearch(false);
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.searchQuery = query;
|
||||||
|
const seq = state.searchSeq;
|
||||||
|
const openSeq = state.openSeq;
|
||||||
|
const pageCount = state.doc.pageCount;
|
||||||
|
try {
|
||||||
|
for (let page = 0; page < pageCount; page += 1) {
|
||||||
|
await waitForPaint();
|
||||||
|
if (seq !== state.searchSeq || openSeq !== state.openSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matches = await callWASM("ofdgoSearchPage", page, query);
|
||||||
|
if (seq !== state.searchSeq || openSeq !== state.openSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const match of matches || []) {
|
||||||
|
state.searchMatches.push({ ...match, page });
|
||||||
|
}
|
||||||
|
el.searchStatus.textContent = `搜索 ${page + 1}/${pageCount} 页`;
|
||||||
|
renderSearchResults();
|
||||||
|
}
|
||||||
|
el.searchStatus.textContent = state.searchMatches.length ? "搜索完成" : "未找到文字";
|
||||||
|
if (state.searchIndex < 0 && state.searchMatches.length && !el.searchPanel.hidden && state.showPages) {
|
||||||
|
await selectSearchMatch(0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (seq === state.searchSeq && openSeq === state.openSeq) {
|
||||||
|
state.searchQuery = "";
|
||||||
|
el.searchStatus.textContent = "搜索失败";
|
||||||
|
showError(err, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchCount() {
|
||||||
|
const count = state.searchMatches.length;
|
||||||
|
el.searchCount.textContent = `${state.searchIndex + 1} / ${count}`;
|
||||||
|
el.searchPrev.disabled = count === 0;
|
||||||
|
el.searchNext.disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults() {
|
||||||
|
const start = Math.floor(Math.max(0, state.searchIndex) / 50) * 50;
|
||||||
|
if (el.searchResults.firstElementChild && Number(el.searchResults.firstElementChild.dataset.matchIndex) !== start) {
|
||||||
|
el.searchResults.replaceChildren();
|
||||||
|
}
|
||||||
|
for (let i = start + el.searchResults.childElementCount; i < Math.min(start + 50, state.searchMatches.length); i += 1) {
|
||||||
|
const match = state.searchMatches[i];
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "search-result";
|
||||||
|
button.dataset.matchIndex = String(i);
|
||||||
|
const page = document.createElement("span");
|
||||||
|
page.className = "search-page";
|
||||||
|
page.textContent = `第 ${match.page + 1} 页`;
|
||||||
|
const text = document.createElement("span");
|
||||||
|
const mark = document.createElement("mark");
|
||||||
|
mark.textContent = match.text;
|
||||||
|
text.append(match.before, mark, match.after);
|
||||||
|
button.append(page, text);
|
||||||
|
button.addEventListener("click", () => selectSearchMatch(i));
|
||||||
|
el.searchResults.append(button);
|
||||||
|
}
|
||||||
|
for (const button of el.searchResults.children) {
|
||||||
|
button.setAttribute("aria-current", String(Number(button.dataset.matchIndex) === state.searchIndex));
|
||||||
|
}
|
||||||
|
updateSearchCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSearchMatch(index) {
|
||||||
|
const count = state.searchMatches.length;
|
||||||
|
if (!count || document.body.hasAttribute("aria-busy")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
index = (index + count) % count;
|
||||||
|
state.searchIndex = index;
|
||||||
|
const match = state.searchMatches[index];
|
||||||
|
const seq = state.searchSeq;
|
||||||
|
const openSeq = state.openSeq;
|
||||||
|
clearSearchHighlights();
|
||||||
|
renderSearchResults();
|
||||||
|
if (!await renderPage(match.page, { fit: false, scroll: false })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextFrame();
|
||||||
|
if (seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderSearchHighlights(match.page);
|
||||||
|
const mark = pageShell(match.page).querySelector(".search-highlight");
|
||||||
|
if (mark) {
|
||||||
|
const box = mark.getBoundingClientRect();
|
||||||
|
const viewer = el.viewerPanel.getBoundingClientRect();
|
||||||
|
el.viewerPanel.scrollTop += box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2;
|
||||||
|
if (box.left < viewer.left) {
|
||||||
|
el.viewerPanel.scrollLeft += box.left - viewer.left;
|
||||||
|
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
|
||||||
|
el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollToPage(match.page);
|
||||||
|
}
|
||||||
|
const button = el.searchResults.querySelector("[aria-current=true]");
|
||||||
|
const row = button.getBoundingClientRect();
|
||||||
|
const panel = el.searchResults.getBoundingClientRect();
|
||||||
|
if (row.top < panel.top) {
|
||||||
|
el.searchResults.scrollTop += row.top - panel.top;
|
||||||
|
} else if (row.bottom > panel.bottom) {
|
||||||
|
el.searchResults.scrollTop += row.bottom - panel.bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearchHighlights() {
|
||||||
|
for (const mark of el.svgHost.querySelectorAll(".search-highlight")) {
|
||||||
|
mark.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchHighlights(index) {
|
||||||
|
const match = state.searchMatches[state.searchIndex];
|
||||||
|
if (!match || match.page !== index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const surface = pageShell(index).querySelector(".page-surface");
|
||||||
|
for (const mark of surface.querySelectorAll(".search-highlight")) {
|
||||||
|
mark.remove();
|
||||||
|
}
|
||||||
|
for (const box of match.boxes || []) {
|
||||||
|
const mark = document.createElement("span");
|
||||||
|
mark.className = "search-highlight";
|
||||||
|
mark.style.left = `${box.X * MM_TO_PX}px`;
|
||||||
|
mark.style.top = `${box.Y * MM_TO_PX}px`;
|
||||||
|
mark.style.width = `${box.W * MM_TO_PX}px`;
|
||||||
|
mark.style.height = `${box.H * MM_TO_PX}px`;
|
||||||
|
surface.append(mark);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOutlineList(outlines) {
|
function createOutlineList(outlines) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoDocumentInfo", documentInfo)
|
registerCallback("ofdgoDocumentInfo", documentInfo)
|
||||||
registerCallback("ofdgoAttachmentData", attachmentData)
|
registerCallback("ofdgoAttachmentData", attachmentData)
|
||||||
registerCallback("ofdgoRenderPage", renderPage)
|
registerCallback("ofdgoRenderPage", renderPage)
|
||||||
|
registerCallback("ofdgoSearchPage", searchPage)
|
||||||
registerCallback("ofdgoExportFormats", exportFormats)
|
registerCallback("ofdgoExportFormats", exportFormats)
|
||||||
registerCallback("ofdgoExportPage", exportPage)
|
registerCallback("ofdgoExportPage", exportPage)
|
||||||
registerCallback("ofdgoExportPDF", exportPDF)
|
registerCallback("ofdgoExportPDF", exportPDF)
|
||||||
@@ -118,7 +119,10 @@ func configureDocument(args []js.Value) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if currentSession.Renderer.RenderAnnotations != args[1].Bool() {
|
||||||
currentSession.Renderer.RenderAnnotations = args[1].Bool()
|
currentSession.Renderer.RenderAnnotations = args[1].Bool()
|
||||||
|
clear(currentSession.textCache)
|
||||||
|
}
|
||||||
return currentSession.Summary(), nil
|
return currentSession.Summary(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,16 @@ func renderPage(args []js.Value) (any, error) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchPage 搜索OFD页面文字
|
||||||
|
// 入参: args 浏览器参数
|
||||||
|
// 返回: any 搜索结果, error 错误信息
|
||||||
|
func searchPage(args []js.Value) (any, error) {
|
||||||
|
if currentSession == nil {
|
||||||
|
return nil, fmt.Errorf("ofd document is not opened")
|
||||||
|
}
|
||||||
|
return currentSession.SearchPage(args[0].Int(), args[1].String())
|
||||||
|
}
|
||||||
|
|
||||||
// exportFormats 获取导出格式
|
// exportFormats 获取导出格式
|
||||||
// 入参: args 浏览器参数
|
// 入参: args 浏览器参数
|
||||||
// 返回: any 导出格式, error 错误信息
|
// 返回: any 导出格式, error 错误信息
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type Session struct {
|
|||||||
doc *ofdgo.Document
|
doc *ofdgo.Document
|
||||||
pageCache map[int]*ofdgo.PageContent
|
pageCache map[int]*ofdgo.PageContent
|
||||||
boxCache map[int]ofdgo.Box
|
boxCache map[int]ofdgo.Box
|
||||||
|
textCache map[int]*ofdgo.PageText
|
||||||
signatures []SignatureInfo
|
signatures []SignatureInfo
|
||||||
signatureError error
|
signatureError error
|
||||||
signaturesRead bool
|
signaturesRead bool
|
||||||
@@ -239,6 +240,7 @@ func Open(data []byte, opts OpenOptions) (*Session, error) {
|
|||||||
doc: doc,
|
doc: doc,
|
||||||
pageCache: make(map[int]*ofdgo.PageContent),
|
pageCache: make(map[int]*ofdgo.PageContent),
|
||||||
boxCache: make(map[int]ofdgo.Box),
|
boxCache: make(map[int]ofdgo.Box),
|
||||||
|
textCache: make(map[int]*ofdgo.PageText),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +266,29 @@ func (s *Session) SetFonts(fonts []FontFile) error {
|
|||||||
} else {
|
} else {
|
||||||
s.Renderer.SetFontFS()
|
s.Renderer.SetFontFS()
|
||||||
}
|
}
|
||||||
|
clear(s.textCache)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchPage 搜索指定页面,复用当前会话的文字索引
|
||||||
|
// 入参: index 页面索引, query 搜索文字
|
||||||
|
// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息
|
||||||
|
func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) {
|
||||||
|
text := s.textCache[index]
|
||||||
|
if text == nil {
|
||||||
|
_, page, err := s.pageContent(index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, err = s.Renderer.PageText(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.textCache[index] = text
|
||||||
|
}
|
||||||
|
return text.Search(query), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Summary 获取首屏所需信息,不解析页面图元或执行验签
|
// Summary 获取首屏所需信息,不解析页面图元或执行验签
|
||||||
// 返回: DocumentInfo 文档信息
|
// 返回: DocumentInfo 文档信息
|
||||||
func (s *Session) Summary() DocumentInfo {
|
func (s *Session) Summary() DocumentInfo {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Renderer struct {
|
|||||||
fontDirs []string
|
fontDirs []string
|
||||||
fontFS []fs.FS
|
fontFS []fs.FS
|
||||||
decodeImages bool
|
decodeImages bool
|
||||||
|
pageText *PageText
|
||||||
}
|
}
|
||||||
|
|
||||||
// RendererOption 渲染器配置选项
|
// RendererOption 渲染器配置选项
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import (
|
|||||||
// renderImage 渲染图片
|
// renderImage 渲染图片
|
||||||
// 入参: ctx 画布上下文, obj 图片对象, pageH 页面高度, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM, parentClip 父级裁剪路径
|
// 入参: ctx 画布上下文, obj 图片对象, pageH 页面高度, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM, parentClip 父级裁剪路径
|
||||||
func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) {
|
func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) {
|
||||||
|
if r.pageText != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if obj.Visible != nil && !*obj.Visible {
|
if obj.Visible != nil && !*obj.Visible {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ func (s *pathStyle) scale(ctm Matrix) {
|
|||||||
// renderPath 渲染路径
|
// renderPath 渲染路径
|
||||||
// 入参: ctx 画布上下文, obj 路径对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径
|
// 入参: ctx 画布上下文, obj 路径对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径
|
||||||
func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) {
|
func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) {
|
||||||
|
if r.pageText != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if obj.Visible != nil && !*obj.Visible {
|
if obj.Visible != nil && !*obj.Visible {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -499,7 +502,9 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ob
|
|||||||
textObj.Alpha = nil
|
textObj.Alpha = nil
|
||||||
textObj.FillColor = &FillColor{Value: "0 0 0"}
|
textObj.FillColor = &FillColor{Value: "0 0 0"}
|
||||||
textObj.StrokeColor = &StrokeColor{Value: "0 0 0"}
|
textObj.StrokeColor = &StrokeColor{Value: "0 0 0"}
|
||||||
r.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil)
|
textRenderer := *r
|
||||||
|
textRenderer.pageText = nil
|
||||||
|
textRenderer.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if renderer.path == nil {
|
if renderer.path == nil {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import (
|
|||||||
// renderStamp 渲染印章
|
// renderStamp 渲染印章
|
||||||
// 入参: ctx 画布上下文, s 印章对象, pageH 页面高度
|
// 入参: ctx 画布上下文, s 印章对象, pageH 页面高度
|
||||||
func (r *Renderer) renderStamp(ctx *canvas.Context, s Stamp, pageH float64) {
|
func (r *Renderer) renderStamp(ctx *canvas.Context, s Stamp, pageH float64) {
|
||||||
|
if r.pageText != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.Type == "ofd" && len(s.Data) > 0 {
|
if s.Type == "ofd" && len(s.Data) > 0 {
|
||||||
if s.Clip != nil {
|
if s.Clip != nil {
|
||||||
if img := r.renderOFDStampImage(s.Data); img != nil {
|
if img := r.renderOFDStampImage(s.Data); img != nil {
|
||||||
|
|||||||
@@ -153,7 +153,28 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
fontStyle |= canvas.FontBold
|
fontStyle |= canvas.FontBold
|
||||||
}
|
}
|
||||||
ff := r.loadFont(fontID)
|
ff := r.loadFont(fontID)
|
||||||
|
var textRun *TextRun
|
||||||
|
if r.pageText != nil {
|
||||||
|
textRun = r.pageText.addRun(obj)
|
||||||
|
}
|
||||||
if ff == nil {
|
if ff == nil {
|
||||||
|
if textRun != nil {
|
||||||
|
box, _ := ParseBox(obj.Boundary)
|
||||||
|
boundaryCTM := TranslationMatrix(box.X, box.Y)
|
||||||
|
if parentCTM != nil {
|
||||||
|
if boundaryInCTM {
|
||||||
|
boundaryCTM = parentCTM.Multiply(boundaryCTM)
|
||||||
|
} else {
|
||||||
|
boundaryCTM = boundaryCTM.Multiply(*parentCTM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := canvas.Matrix{{boundaryCTM.a, -boundaryCTM.c, boundaryCTM.e}, {-boundaryCTM.b, boundaryCTM.d, pageH - boundaryCTM.f}}
|
||||||
|
path := canvas.Rectangle(box.W, box.H).Translate(0, -box.H).Transform(m)
|
||||||
|
box = textPathBox(applyClipPath(path, clipPath), pageH)
|
||||||
|
for i := range textRun.Boxes {
|
||||||
|
textRun.Boxes[i] = box
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.Pop()
|
ctx.Pop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -172,6 +193,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
advanceX, advanceY = 0, -1
|
advanceX, advanceY = 0, -1
|
||||||
}
|
}
|
||||||
codePos := 0
|
codePos := 0
|
||||||
|
textPos := 0
|
||||||
for _, tc := range obj.TextCode {
|
for _, tc := range obj.TextCode {
|
||||||
var runes []rune
|
var runes []rune
|
||||||
var glyphs []textGlyph
|
var glyphs []textGlyph
|
||||||
@@ -182,6 +204,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
runes = textCodeRunes(tc.Value)
|
runes = textCodeRunes(tc.Value)
|
||||||
glyphs = textCodeGlyphs(runes, glyphTransforms, codePos)
|
glyphs = textCodeGlyphs(runes, glyphTransforms, codePos)
|
||||||
}
|
}
|
||||||
|
var spans [][2]int
|
||||||
|
if textRun != nil && tc.Index == "" {
|
||||||
|
spans = textGlyphSpans(runes, glyphTransforms, codePos)
|
||||||
|
}
|
||||||
dxs, dys := parseFloats(tc.DeltaX), parseFloats(tc.DeltaY)
|
dxs, dys := parseFloats(tc.DeltaX), parseFloats(tc.DeltaY)
|
||||||
xs, ys := parseFloats(tc.X), parseFloats(tc.Y)
|
xs, ys := parseFloats(tc.X), parseFloats(tc.Y)
|
||||||
drawAsPath := embeddedFont || face.FauxBold > 0 || textCodePositioned(tc, xs, ys) || fillClip != nil || shadedFill || fillPattern != nil || shouldStroke
|
drawAsPath := embeddedFont || face.FauxBold > 0 || textCodePositioned(tc, xs, ys) || fillClip != nil || shadedFill || fillPattern != nil || shouldStroke
|
||||||
@@ -247,6 +273,27 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
if obj.CharDirection == 0 {
|
if obj.CharDirection == 0 {
|
||||||
advanceLimit = textGlyphAdvanceLimit(dxs, dys, xs, i, len(glyphs), cx)
|
advanceLimit = textGlyphAdvanceLimit(dxs, dys, xs, i, len(glyphs), cx)
|
||||||
}
|
}
|
||||||
|
if textRun != nil {
|
||||||
|
if i < len(spans) {
|
||||||
|
if glyphPath == nil {
|
||||||
|
glyphPath, _ = r.cachedTextGlyphPath(face, glyph)
|
||||||
|
}
|
||||||
|
scaleX := hScale
|
||||||
|
if drawAsGlyphPath && advanceLimit > 0 && glyphWidth*scaleX > advanceLimit {
|
||||||
|
scaleX = advanceLimit / glyphWidth
|
||||||
|
}
|
||||||
|
m := canvas.Identity.Translate(canvasX, canvasY)
|
||||||
|
if useTextMatrix {
|
||||||
|
m = m.Mul(glyphMatrix)
|
||||||
|
}
|
||||||
|
path := glyphPath.Copy().Transform(m.Scale(scaleX, 1))
|
||||||
|
box := textPathBox(applyClipPath(path, clipPath), pageH)
|
||||||
|
for j := spans[i][0]; j < spans[i][1]; j++ {
|
||||||
|
textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if shouldStroke {
|
if shouldStroke {
|
||||||
scaleX := hScale
|
scaleX := hScale
|
||||||
if advanceLimit > 0 && glyphWidth*scaleX > advanceLimit {
|
if advanceLimit > 0 && glyphWidth*scaleX > advanceLimit {
|
||||||
@@ -392,6 +439,13 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
codePos += len(runes)
|
codePos += len(runes)
|
||||||
|
if textRun != nil {
|
||||||
|
if tc.Index == "" {
|
||||||
|
textPos += len(runes)
|
||||||
|
} else {
|
||||||
|
textPos++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx.Pop()
|
ctx.Pop()
|
||||||
}
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/tdewolff/canvas"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PageText 页面文字,按绘制顺序保留文本对象,不包含图像和签名外观
|
||||||
|
type PageText struct {
|
||||||
|
Runs []TextRun `json:"runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米
|
||||||
|
// Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域
|
||||||
|
type TextRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Boxes []Box `json:"boxes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间
|
||||||
|
type TextMatch struct {
|
||||||
|
Run int `json:"run"`
|
||||||
|
Start int `json:"start"`
|
||||||
|
End int `json:"end"`
|
||||||
|
Before string `json:"before"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
After string `json:"after"`
|
||||||
|
Boxes []Box `json:"boxes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageText 提取页面及启用注释的原文,复用当前字体配置和渲染位置计算
|
||||||
|
// 入参: page 页面内容
|
||||||
|
// 返回: *PageText 页面文字, error 错误信息
|
||||||
|
func (r *Renderer) PageText(page *PageContent) (*PageText, error) {
|
||||||
|
box, err := r.GetPageBox(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
renderer := *r
|
||||||
|
renderer.pageText = &PageText{}
|
||||||
|
if err := renderer.renderPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return renderer.pageText, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像
|
||||||
|
// 入参: query 搜索文字
|
||||||
|
// 返回: []TextMatch 匹配结果及上下文
|
||||||
|
func (p *PageText) Search(query string) []TextMatch {
|
||||||
|
var matches []TextMatch
|
||||||
|
if query == "" {
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
query = foldText(query)
|
||||||
|
length := utf8.RuneCountInString(query)
|
||||||
|
for index, run := range p.Runs {
|
||||||
|
text := foldText(run.Text)
|
||||||
|
runes := []rune(run.Text)
|
||||||
|
codeOffset := 0
|
||||||
|
for offset := 0; offset < len(text); {
|
||||||
|
found := strings.Index(text[offset:], query)
|
||||||
|
if found < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start := codeOffset + utf8.RuneCountInString(text[offset:offset+found])
|
||||||
|
offset += found
|
||||||
|
end := start + length
|
||||||
|
match := TextMatch{
|
||||||
|
Run: index, Start: start, End: end,
|
||||||
|
Before: string(runes[max(0, start-16):start]),
|
||||||
|
Text: string(runes[start:end]),
|
||||||
|
After: string(runes[end:min(len(runes), end+16)]),
|
||||||
|
}
|
||||||
|
for _, box := range run.Boxes[start:end] {
|
||||||
|
if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) {
|
||||||
|
match.Boxes = append(match.Boxes, box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches = append(matches, match)
|
||||||
|
offset += len(query)
|
||||||
|
codeOffset = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldText 统一Unicode大小写等价字符,保留字符数量
|
||||||
|
// 入参: text 原文
|
||||||
|
// 返回: string 匹配用文字
|
||||||
|
func foldText(text string) string {
|
||||||
|
return strings.Map(func(char rune) rune {
|
||||||
|
folded := char
|
||||||
|
for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) {
|
||||||
|
folded = min(folded, next)
|
||||||
|
}
|
||||||
|
return folded
|
||||||
|
}, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRun 收集原始文本,字形索引不替代Unicode原文
|
||||||
|
// 入参: obj 文本对象
|
||||||
|
// 返回: *TextRun 文本及待填充的字符区域
|
||||||
|
func (p *PageText) addRun(obj TextObject) *TextRun {
|
||||||
|
var text strings.Builder
|
||||||
|
for _, code := range obj.TextCode {
|
||||||
|
if code.Index == "" {
|
||||||
|
text.WriteString(string(textCodeRunes(code.Value)))
|
||||||
|
} else {
|
||||||
|
text.WriteRune('\uFFFC')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Runs = append(p.Runs, TextRun{ID: obj.ID, Text: text.String(), Boxes: make([]Box, utf8.RuneCountInString(text.String()))})
|
||||||
|
return &p.Runs[len(p.Runs)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// textGlyphSpans 将绘制字形映射到原文字符区间
|
||||||
|
// 入参: runes 原文字符, transforms 字形变换, offset 文本编码偏移
|
||||||
|
// 返回: [][2]int 每个字形对应的字符起止位置
|
||||||
|
func textGlyphSpans(runes []rune, transforms map[int]textGlyphTransform, offset int) [][2]int {
|
||||||
|
spans := make([][2]int, 0, len(runes))
|
||||||
|
for i := 0; i < len(runes); {
|
||||||
|
if transform, ok := transforms[offset+i]; ok && i+transform.CodeCount <= len(runes) {
|
||||||
|
for range transform.Glyphs {
|
||||||
|
spans = append(spans, [2]int{i, i + transform.CodeCount})
|
||||||
|
}
|
||||||
|
i += transform.CodeCount
|
||||||
|
} else {
|
||||||
|
spans = append(spans, [2]int{i, i + 1})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// textPathBox 将字形轮廓转换为页面区域
|
||||||
|
// 入参: path 字形轮廓, pageH 页面高度
|
||||||
|
// 返回: Box 字符区域
|
||||||
|
func textPathBox(path *canvas.Path, pageH float64) Box {
|
||||||
|
if path.Empty() {
|
||||||
|
return Box{}
|
||||||
|
}
|
||||||
|
bounds := path.Bounds()
|
||||||
|
return Box{X: bounds.X0, Y: pageH - bounds.Y1, W: bounds.X1 - bounds.X0, H: bounds.Y1 - bounds.Y0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unionTextBox 合并同一字符的字形区域
|
||||||
|
// 入参: a 已有区域, b 新区域
|
||||||
|
// 返回: Box 合并区域
|
||||||
|
func unionTextBox(a, b Box) Box {
|
||||||
|
if a.W <= 0 || a.H <= 0 {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if b.W <= 0 || b.H <= 0 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y)
|
||||||
|
return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user