Merge pull request #33 from scub-france/feature/include-chunking
Feature/include chunking
This commit is contained in:
commit
84645f0d70
20 changed files with 742 additions and 68 deletions
|
|
@ -7,7 +7,13 @@ from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
from api.schemas import AnalysisResponse, ChunkResponse, CreateAnalysisRequest, RechunkRequest
|
from api.schemas import (
|
||||||
|
AnalysisResponse,
|
||||||
|
ChunkBboxResponse,
|
||||||
|
ChunkResponse,
|
||||||
|
CreateAnalysisRequest,
|
||||||
|
RechunkRequest,
|
||||||
|
)
|
||||||
from services.analysis_service import AnalysisService
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -94,6 +100,7 @@ async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDe
|
||||||
headings=c.headings,
|
headings=c.headings,
|
||||||
source_page=c.source_page,
|
source_page=c.source_page,
|
||||||
token_count=c.token_count,
|
token_count=c.token_count,
|
||||||
|
bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes],
|
||||||
)
|
)
|
||||||
for c in chunks
|
for c in chunks
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,17 @@ class ChunkingOptionsRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkBboxResponse(_CamelModel):
|
||||||
|
page: int
|
||||||
|
bbox: list[float]
|
||||||
|
|
||||||
|
|
||||||
class ChunkResponse(_CamelModel):
|
class ChunkResponse(_CamelModel):
|
||||||
text: str
|
text: str
|
||||||
headings: list[str] = []
|
headings: list[str] = []
|
||||||
source_page: int | None = None
|
source_page: int | None = None
|
||||||
token_count: int = 0
|
token_count: int = 0
|
||||||
|
bboxes: list[ChunkBboxResponse] = []
|
||||||
|
|
||||||
|
|
||||||
class CreateAnalysisRequest(BaseModel):
|
class CreateAnalysisRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,16 @@ class ChunkingOptions:
|
||||||
return self == ChunkingOptions()
|
return self == ChunkingOptions()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChunkBbox:
|
||||||
|
page: int
|
||||||
|
bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChunkResult:
|
class ChunkResult:
|
||||||
text: str
|
text: str
|
||||||
headings: list[str] = field(default_factory=list)
|
headings: list[str] = field(default_factory=list)
|
||||||
source_page: int | None = None
|
source_page: int | None = None
|
||||||
token_count: int = 0
|
token_count: int = 0
|
||||||
|
bboxes: list[ChunkBbox] = field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ from docling_core.transforms.chunker import HierarchicalChunker
|
||||||
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
||||||
from docling_core.types.doc.document import DoclingDocument
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
from domain.value_objects import ChunkingOptions, ChunkResult
|
from domain.bbox import EMPTY_BBOX, to_topleft_list
|
||||||
|
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -37,12 +38,22 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
|
||||||
for chunk in chunker.chunk(doc):
|
for chunk in chunker.chunk(doc):
|
||||||
source_page = None
|
source_page = None
|
||||||
token_count = 0
|
token_count = 0
|
||||||
|
bboxes: list[ChunkBbox] = []
|
||||||
|
|
||||||
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
||||||
for doc_item in chunk.meta.doc_items:
|
for doc_item in chunk.meta.doc_items:
|
||||||
if hasattr(doc_item, "prov") and doc_item.prov:
|
if not hasattr(doc_item, "prov") or not doc_item.prov:
|
||||||
source_page = doc_item.prov[0].page_no
|
continue
|
||||||
break
|
for prov in doc_item.prov:
|
||||||
|
page_no = prov.page_no
|
||||||
|
if source_page is None:
|
||||||
|
source_page = page_no
|
||||||
|
if prov.bbox:
|
||||||
|
page_obj = doc.pages.get(page_no)
|
||||||
|
if page_obj:
|
||||||
|
bbox = to_topleft_list(prov.bbox, page_obj.size.height)
|
||||||
|
if bbox != EMPTY_BBOX:
|
||||||
|
bboxes.append(ChunkBbox(page=page_no, bbox=bbox))
|
||||||
|
|
||||||
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
|
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
|
||||||
token_count = chunker.tokenizer.count_tokens(chunk.text)
|
token_count = chunker.tokenizer.count_tokens(chunk.text)
|
||||||
|
|
@ -55,6 +66,7 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
|
||||||
headings=headings,
|
headings=headings,
|
||||||
source_page=source_page,
|
source_page=source_page,
|
||||||
token_count=token_count,
|
token_count=token_count,
|
||||||
|
bboxes=bboxes,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ def _chunk_to_dict(c: ChunkResult) -> dict:
|
||||||
"headings": c.headings,
|
"headings": c.headings,
|
||||||
"sourcePage": c.source_page,
|
"sourcePage": c.source_page,
|
||||||
"tokenCount": c.token_count,
|
"tokenCount": c.token_count,
|
||||||
|
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from api.schemas import ChunkingOptionsRequest, ChunkResponse, RechunkRequest
|
from api.schemas import ChunkBboxResponse, ChunkingOptionsRequest, ChunkResponse, RechunkRequest
|
||||||
from domain.models import AnalysisJob, AnalysisStatus
|
from domain.models import AnalysisJob, AnalysisStatus
|
||||||
from domain.value_objects import ChunkingOptions, ChunkResult
|
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -60,7 +60,52 @@ class TestChunkResult:
|
||||||
def test_serializable(self):
|
def test_serializable(self):
|
||||||
chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10)
|
chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10)
|
||||||
data = asdict(chunk)
|
data = asdict(chunk)
|
||||||
assert data == {"text": "x", "headings": ["h1"], "source_page": 1, "token_count": 10}
|
assert data == {
|
||||||
|
"text": "x",
|
||||||
|
"headings": ["h1"],
|
||||||
|
"source_page": 1,
|
||||||
|
"token_count": 10,
|
||||||
|
"bboxes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkBbox:
|
||||||
|
def test_construction(self):
|
||||||
|
bbox = ChunkBbox(page=1, bbox=[10.0, 20.0, 100.0, 80.0])
|
||||||
|
assert bbox.page == 1
|
||||||
|
assert bbox.bbox == [10.0, 20.0, 100.0, 80.0]
|
||||||
|
|
||||||
|
def test_serializable(self):
|
||||||
|
bbox = ChunkBbox(page=2, bbox=[0.0, 0.0, 50.0, 50.0])
|
||||||
|
data = asdict(bbox)
|
||||||
|
assert data == {"page": 2, "bbox": [0.0, 0.0, 50.0, 50.0]}
|
||||||
|
|
||||||
|
def test_chunk_result_with_bboxes(self):
|
||||||
|
chunk = ChunkResult(
|
||||||
|
text="content",
|
||||||
|
bboxes=[
|
||||||
|
ChunkBbox(page=1, bbox=[10, 20, 100, 80]),
|
||||||
|
ChunkBbox(page=2, bbox=[50, 50, 150, 250]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert len(chunk.bboxes) == 2
|
||||||
|
assert chunk.bboxes[0].page == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkBboxResponse:
|
||||||
|
def test_serializes(self):
|
||||||
|
resp = ChunkBboxResponse(page=1, bbox=[10.0, 20.0, 100.0, 80.0])
|
||||||
|
data = resp.model_dump(by_alias=True)
|
||||||
|
assert data == {"page": 1, "bbox": [10.0, 20.0, 100.0, 80.0]}
|
||||||
|
|
||||||
|
def test_chunk_response_with_bboxes(self):
|
||||||
|
resp = ChunkResponse(
|
||||||
|
text="hello",
|
||||||
|
bboxes=[ChunkBboxResponse(page=1, bbox=[10, 20, 100, 80])],
|
||||||
|
)
|
||||||
|
data = resp.model_dump(by_alias=True)
|
||||||
|
assert len(data["bboxes"]) == 1
|
||||||
|
assert data["bboxes"][0]["page"] == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -282,6 +327,28 @@ class TestRechunkEndpoint:
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_rechunk_returns_bboxes(self, client, mock_analysis_service):
|
||||||
|
mock_analysis_service.rechunk = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
ChunkResult(
|
||||||
|
text="chunk1",
|
||||||
|
source_page=1,
|
||||||
|
token_count=10,
|
||||||
|
bboxes=[ChunkBbox(page=1, bbox=[10, 20, 100, 80])],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/analyses/j1/rechunk",
|
||||||
|
json={"chunkingOptions": {"chunker_type": "hybrid"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data[0]["bboxes"]) == 1
|
||||||
|
assert data[0]["bboxes"][0]["page"] == 1
|
||||||
|
assert data[0]["bboxes"][0]["bbox"] == [10, 20, 100, 80]
|
||||||
|
|
||||||
def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service):
|
def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/analyses/j1/rechunk",
|
"/api/analyses/j1/rechunk",
|
||||||
|
|
|
||||||
72
frontend/package-lock.json
generated
72
frontend/package-lock.json
generated
|
|
@ -8,7 +8,6 @@
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/mocker": "^4.1.2",
|
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -19,6 +18,7 @@
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vitest/mocker": "^4.1.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
|
|
@ -93,6 +94,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -108,6 +110,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -123,6 +126,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -138,6 +142,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -153,6 +158,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -168,6 +174,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -183,6 +190,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -198,6 +206,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -213,6 +222,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -228,6 +238,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -243,6 +254,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -258,6 +270,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -273,6 +286,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -288,6 +302,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -303,6 +318,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -318,6 +334,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -333,6 +350,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
|
@ -348,6 +366,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
|
@ -363,6 +382,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -378,6 +398,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -393,6 +414,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
|
|
@ -408,6 +430,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
|
|
@ -423,6 +446,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -438,6 +462,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -453,6 +478,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -661,6 +687,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -673,6 +700,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -685,6 +713,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -697,6 +726,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -709,6 +739,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -721,6 +752,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -733,6 +765,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -745,6 +778,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -757,6 +791,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -769,6 +804,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -781,6 +817,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -793,6 +830,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -805,6 +843,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -817,6 +856,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -829,6 +869,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -841,6 +882,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -853,6 +895,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -865,6 +908,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -877,6 +921,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -889,6 +934,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -901,6 +947,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
|
|
@ -913,6 +960,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -925,6 +973,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -937,6 +986,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -949,6 +999,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -989,7 +1040,8 @@
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
|
|
@ -1307,6 +1359,7 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.2",
|
"@vitest/spy": "4.1.2",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
|
|
@ -1372,6 +1425,7 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||||
|
"dev": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
|
|
@ -1828,7 +1882,7 @@
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
|
|
@ -2055,6 +2109,7 @@
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2099,7 +2154,7 @@
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2163,6 +2218,7 @@
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2540,7 +2596,7 @@
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -2655,7 +2711,7 @@
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -2797,7 +2853,7 @@
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.3"
|
||||||
|
|
@ -2909,7 +2965,7 @@
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
||||||
import type { Page, PageElement } from '../../../shared/types'
|
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
|
||||||
|
|
||||||
const ELEMENT_COLORS: Record<string, string> = {
|
const ELEMENT_COLORS: Record<string, string> = {
|
||||||
title: '#EF4444',
|
title: '#EF4444',
|
||||||
|
|
@ -52,11 +52,15 @@ const ELEMENT_COLORS: Record<string, string> = {
|
||||||
caption: '#EAB308'
|
caption: '#EAB308'
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
imageEl: HTMLImageElement | null
|
defineProps<{
|
||||||
pageData: Page | null
|
imageEl: HTMLImageElement | null
|
||||||
highlightedIndex: number
|
pageData: Page | null
|
||||||
}>()
|
highlightedIndex: number
|
||||||
|
highlightedBboxes: ChunkBbox[]
|
||||||
|
}>(),
|
||||||
|
{ highlightedBboxes: () => [] },
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlight-element': [index: number]
|
'highlight-element': [index: number]
|
||||||
|
|
@ -114,6 +118,8 @@ function draw(): void {
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
||||||
|
|
||||||
|
const hasChunkHighlight = props.highlightedBboxes.length > 0
|
||||||
|
|
||||||
for (const el of visibleElements.value) {
|
for (const el of visibleElements.value) {
|
||||||
const rect = bboxToRect(el.bbox, scale)
|
const rect = bboxToRect(el.bbox, scale)
|
||||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||||
|
|
@ -121,13 +127,29 @@ function draw(): void {
|
||||||
const elContentIdx = contentElements.value.indexOf(el)
|
const elContentIdx = contentElements.value.indexOf(el)
|
||||||
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
||||||
|
|
||||||
ctx.strokeStyle = color
|
// Dim non-highlighted elements when a chunk is hovered
|
||||||
|
const dimmed = hasChunkHighlight && !isHighlighted
|
||||||
|
|
||||||
|
ctx.strokeStyle = dimmed ? color + '40' : color
|
||||||
ctx.lineWidth = isHighlighted ? 3 : 2
|
ctx.lineWidth = isHighlighted ? 3 : 2
|
||||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
|
||||||
ctx.fillStyle = color + (isHighlighted ? '40' : '20')
|
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
|
||||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw chunk-highlighted bboxes on top with accent color
|
||||||
|
if (hasChunkHighlight) {
|
||||||
|
const CHUNK_COLOR = '#F59E0B'
|
||||||
|
for (const cb of props.highlightedBboxes) {
|
||||||
|
const rect = bboxToRect(cb.bbox, scale)
|
||||||
|
ctx.strokeStyle = CHUNK_COLOR
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
ctx.fillStyle = CHUNK_COLOR + '30'
|
||||||
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent): void {
|
function onMouseMove(e: MouseEvent): void {
|
||||||
|
|
@ -173,7 +195,7 @@ onBeforeUnmount(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redraw when data or image changes
|
// Redraw when data or image changes
|
||||||
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, hiddenTypes], () => {
|
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, () => props.highlightedBboxes, hiddenTypes], () => {
|
||||||
nextTick(draw)
|
nextTick(draw)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,14 @@ describe('analysis store — chunking', () => {
|
||||||
it('currentChunks parses chunksJson from current analysis', () => {
|
it('currentChunks parses chunksJson from current analysis', () => {
|
||||||
const store = useAnalysisStore()
|
const store = useAnalysisStore()
|
||||||
const chunks = [
|
const chunks = [
|
||||||
{ text: 'chunk1', headings: ['H1'], sourcePage: 1, tokenCount: 10 },
|
{
|
||||||
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20 },
|
text: 'chunk1',
|
||||||
|
headings: ['H1'],
|
||||||
|
sourcePage: 1,
|
||||||
|
tokenCount: 10,
|
||||||
|
bboxes: [{ page: 1, bbox: [10, 20, 100, 80] }],
|
||||||
|
},
|
||||||
|
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20, bboxes: [] },
|
||||||
]
|
]
|
||||||
store.currentAnalysis = {
|
store.currentAnalysis = {
|
||||||
id: 'j1',
|
id: 'j1',
|
||||||
|
|
@ -64,7 +70,7 @@ describe('analysis store — chunking', () => {
|
||||||
|
|
||||||
it('rechunk calls API and refreshes analysis', async () => {
|
it('rechunk calls API and refreshes analysis', async () => {
|
||||||
const store = useAnalysisStore()
|
const store = useAnalysisStore()
|
||||||
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5 }]
|
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5, bboxes: [] }]
|
||||||
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
|
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
|
||||||
vi.mocked(api.fetchAnalysis).mockResolvedValue({
|
vi.mocked(api.fetchAnalysis).mockResolvedValue({
|
||||||
id: 'j1',
|
id: 'j1',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="chunk-panel">
|
<div class="chunk-panel">
|
||||||
<!-- Chunking config -->
|
<!-- Chunking config — collapsible -->
|
||||||
<div class="chunk-config">
|
<div class="chunk-config">
|
||||||
<div class="config-section">
|
<button class="config-toggle" @click="configOpen = !configOpen">
|
||||||
<label class="config-label">{{ t('chunking.settings') }}</label>
|
<svg
|
||||||
|
class="config-chevron"
|
||||||
|
:class="{ open: configOpen }"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="config-label">{{ t('chunking.settings') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="configOpen" class="config-body">
|
||||||
<div class="config-row">
|
<div class="config-row">
|
||||||
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
|
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
|
||||||
<select class="config-select" v-model="options.chunker_type">
|
<select class="config-select" v-model="options.chunker_type">
|
||||||
|
|
@ -40,31 +54,34 @@
|
||||||
<span class="toggle-text">{{ t('chunking.repeatTableHeader') }}</span>
|
<span class="toggle-text">{{ t('chunking.repeatTableHeader') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="chunk-btn primary"
|
class="chunk-btn primary"
|
||||||
:disabled="!canRechunk || analysisStore.rechunking"
|
:disabled="!canRechunk || analysisStore.rechunking"
|
||||||
@click="doRechunk"
|
@click="doRechunk"
|
||||||
>
|
>
|
||||||
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
||||||
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chunks list -->
|
<!-- Chunks list -->
|
||||||
<div class="chunk-results" v-if="analysisStore.currentChunks.length">
|
<div class="chunk-results" v-if="pageChunks.length">
|
||||||
<div class="chunk-summary">
|
<div class="chunk-summary">
|
||||||
{{ analysisStore.currentChunks.length }} {{ t('chunking.chunks') }}
|
{{ pagination.totalItems.value }} {{ t('chunking.chunks') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="chunk-list">
|
<div class="chunk-list">
|
||||||
<div
|
<div
|
||||||
class="chunk-card"
|
class="chunk-card"
|
||||||
v-for="(chunk, idx) in analysisStore.currentChunks"
|
v-for="(chunk, localIdx) in pagination.paginatedItems.value"
|
||||||
:key="idx"
|
:key="globalIndex(localIdx)"
|
||||||
|
:class="{ highlighted: hoveredChunkIdx === globalIndex(localIdx) }"
|
||||||
|
@mouseenter="onChunkHover(chunk, localIdx)"
|
||||||
|
@mouseleave="onChunkLeave"
|
||||||
>
|
>
|
||||||
<div class="chunk-header">
|
<div class="chunk-header">
|
||||||
<span class="chunk-index">#{{ idx + 1 }}</span>
|
<span class="chunk-index">#{{ globalIndex(localIdx) + 1 }}</span>
|
||||||
<span class="chunk-tokens" v-if="chunk.tokenCount">
|
<span class="chunk-tokens" v-if="chunk.tokenCount">
|
||||||
{{ chunk.tokenCount }} tokens
|
{{ chunk.tokenCount }} tokens
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -81,20 +98,47 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
|
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
|
||||||
<p>{{ t('chunking.noChunks') }}</p>
|
<p>
|
||||||
|
{{
|
||||||
|
analysisStore.currentChunks.length
|
||||||
|
? t('chunking.noChunksOnPage')
|
||||||
|
: t('chunking.noChunks')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<PaginationBar
|
||||||
|
:page="pagination.page.value"
|
||||||
|
:page-count="pagination.pageCount.value"
|
||||||
|
:page-size="pagination.pageSize.value"
|
||||||
|
@update:page="pagination.goTo($event)"
|
||||||
|
@update:page-size="pagination.setPageSize($event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { useAnalysisStore } from '../../analysis/store'
|
import { useAnalysisStore } from '../../analysis/store'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import type { ChunkingOptions } from '../../../shared/types'
|
import { usePagination } from '../../../shared/composables/usePagination'
|
||||||
|
import { PaginationBar } from '../../../shared/ui'
|
||||||
|
import type { Chunk, ChunkBbox, ChunkingOptions } from '../../../shared/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentPage: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'highlight-bboxes': [bboxes: ChunkBbox[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
const analysisStore = useAnalysisStore()
|
const analysisStore = useAnalysisStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const configOpen = ref(true)
|
||||||
|
|
||||||
const options = reactive<Required<ChunkingOptions>>({
|
const options = reactive<Required<ChunkingOptions>>({
|
||||||
chunker_type: 'hybrid',
|
chunker_type: 'hybrid',
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
|
|
@ -107,6 +151,28 @@ const canRechunk = computed(() => {
|
||||||
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pageChunks = computed(() =>
|
||||||
|
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
|
||||||
|
)
|
||||||
|
const pagination = usePagination(pageChunks, { pageSize: 20 })
|
||||||
|
|
||||||
|
function globalIndex(localIdx: number): number {
|
||||||
|
return (pagination.page.value - 1) * pagination.pageSize.value + localIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveredChunkIdx = ref(-1)
|
||||||
|
|
||||||
|
function onChunkHover(chunk: Chunk, localIdx: number) {
|
||||||
|
hoveredChunkIdx.value = globalIndex(localIdx)
|
||||||
|
const pageBboxes = chunk.bboxes.filter((b) => b.page === props.currentPage)
|
||||||
|
emit('highlight-bboxes', pageBboxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChunkLeave() {
|
||||||
|
hoveredChunkIdx.value = -1
|
||||||
|
emit('highlight-bboxes', [])
|
||||||
|
}
|
||||||
|
|
||||||
async function doRechunk() {
|
async function doRechunk() {
|
||||||
if (!analysisStore.currentAnalysis) return
|
if (!analysisStore.currentAnalysis) return
|
||||||
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
|
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
|
||||||
|
|
@ -122,17 +188,35 @@ async function doRechunk() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-config {
|
.chunk-config {
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
flex-shrink: 0;
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section {
|
.config-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-chevron {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-label {
|
.config-label {
|
||||||
|
|
@ -140,7 +224,13 @@ async function doRechunk() {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-secondary);
|
}
|
||||||
|
|
||||||
|
.config-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-label-sm {
|
.config-label-sm {
|
||||||
|
|
@ -225,6 +315,7 @@ async function doRechunk() {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-btn.primary {
|
.chunk-btn.primary {
|
||||||
|
|
@ -241,6 +332,7 @@ async function doRechunk() {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-summary {
|
.chunk-summary {
|
||||||
|
|
@ -263,6 +355,17 @@ async function doRechunk() {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
cursor: default;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card:hover {
|
||||||
|
border-color: #F59E0B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card.highlighted {
|
||||||
|
border-color: #F59E0B;
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-header {
|
.chunk-header {
|
||||||
|
|
@ -331,6 +434,8 @@ async function doRechunk() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,6 @@ vi.mock('../../shared/api/http', () => ({
|
||||||
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../settings/store', () => ({
|
|
||||||
useSettingsStore: () => ({ apiUrl: 'http://localhost:8000' }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('useFeatureFlagStore', () => {
|
describe('useFeatureFlagStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiFetch } from '../../shared/api/http'
|
import { apiFetch } from '../../shared/api/http'
|
||||||
import { useSettingsStore } from '../settings/store'
|
|
||||||
|
|
||||||
type ConversionEngine = 'local' | 'remote'
|
type ConversionEngine = 'local' | 'remote'
|
||||||
|
|
||||||
|
|
@ -44,9 +43,8 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
const settings = useSettingsStore()
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<HealthResponse>(`${settings.apiUrl}/health`)
|
const data = await apiFetch<HealthResponse>('/health')
|
||||||
engine.value = data.engine
|
engine.value = data.engine
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
|
||||||
|
|
@ -136,11 +136,12 @@
|
||||||
@load="onPdfImageLoad"
|
@load="onPdfImageLoad"
|
||||||
/>
|
/>
|
||||||
<BboxOverlay
|
<BboxOverlay
|
||||||
v-if="visualMode && hasAnalysisResults"
|
v-if="(visualMode || mode === 'preparer') && hasAnalysisResults"
|
||||||
ref="bboxOverlayRef"
|
ref="bboxOverlayRef"
|
||||||
:image-el="pdfImageRef"
|
:image-el="pdfImageRef"
|
||||||
:page-data="currentPageData"
|
:page-data="currentPageData"
|
||||||
:highlighted-index="highlightedElementIndex"
|
:highlighted-index="highlightedElementIndex"
|
||||||
|
:highlighted-bboxes="highlightedChunkBboxes"
|
||||||
@highlight-element="highlightedElementIndex = $event"
|
@highlight-element="highlightedElementIndex = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -292,7 +293,10 @@
|
||||||
|
|
||||||
<!-- PREPARER MODE (feature-flipped) -->
|
<!-- PREPARER MODE (feature-flipped) -->
|
||||||
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
|
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
|
||||||
<ChunkPanel />
|
<ChunkPanel
|
||||||
|
:current-page="currentPage"
|
||||||
|
@highlight-bboxes="highlightedChunkBboxes = $event"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -311,7 +315,7 @@ import { ChunkPanel } from '../features/chunking'
|
||||||
import { useFeatureFlag } from '../features/feature-flags'
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
import { getPreviewUrl } from '../features/document/api'
|
import { getPreviewUrl } from '../features/document/api'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { useI18n } from '../shared/i18n'
|
||||||
import type { PipelineOptions } from '../shared/types'
|
import type { ChunkBbox, PipelineOptions } from '../shared/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -324,6 +328,7 @@ const mode = ref('configurer')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const visualMode = ref(false)
|
const visualMode = ref(false)
|
||||||
const highlightedElementIndex = ref(-1)
|
const highlightedElementIndex = ref(-1)
|
||||||
|
const highlightedChunkBboxes = ref<ChunkBbox[]>([])
|
||||||
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
const pdfImageRef = ref<HTMLImageElement | null>(null)
|
||||||
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
const bboxOverlayRef = ref<InstanceType<typeof BboxOverlay> | null>(null)
|
||||||
|
|
||||||
|
|
@ -410,6 +415,16 @@ function addMore() {
|
||||||
documentStore.selectedId = null
|
documentStore.selectedId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear highlights when switching modes or pages
|
||||||
|
watch(mode, () => {
|
||||||
|
highlightedElementIndex.value = -1
|
||||||
|
highlightedChunkBboxes.value = []
|
||||||
|
})
|
||||||
|
watch(currentPage, () => {
|
||||||
|
highlightedElementIndex.value = -1
|
||||||
|
highlightedChunkBboxes.value = []
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
// Auto-switch to verifier when analysis completes + refresh document data (pageCount)
|
||||||
watch(() => analysisStore.currentAnalysis?.status, (status) => {
|
watch(() => analysisStore.currentAnalysis?.status, (status) => {
|
||||||
if (status === 'COMPLETED') {
|
if (status === 'COMPLETED') {
|
||||||
|
|
@ -1136,7 +1151,8 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verify panel */
|
/* Verify panel */
|
||||||
.verify-panel {
|
.verify-panel,
|
||||||
|
.prepare-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
149
frontend/src/shared/composables/usePagination.test.ts
Normal file
149
frontend/src/shared/composables/usePagination.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { usePagination } from './usePagination'
|
||||||
|
|
||||||
|
function makeItems(n: number): string[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => `item-${i + 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePagination', () => {
|
||||||
|
it('returns all items when total fits in one page', () => {
|
||||||
|
const items = ref(makeItems(5))
|
||||||
|
const { paginatedItems, pageCount, page } = usePagination(items, { pageSize: 20 })
|
||||||
|
|
||||||
|
expect(paginatedItems.value).toHaveLength(5)
|
||||||
|
expect(pageCount.value).toBe(1)
|
||||||
|
expect(page.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('slices items according to page size', () => {
|
||||||
|
const items = ref(makeItems(25))
|
||||||
|
const { paginatedItems, pageCount } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
expect(paginatedItems.value).toHaveLength(10)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-1')
|
||||||
|
expect(pageCount.value).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates with next and prev', () => {
|
||||||
|
const items = ref(makeItems(30))
|
||||||
|
const { paginatedItems, page, next, prev } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
next()
|
||||||
|
expect(page.value).toBe(2)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-11')
|
||||||
|
|
||||||
|
next()
|
||||||
|
expect(page.value).toBe(3)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-21')
|
||||||
|
|
||||||
|
prev()
|
||||||
|
expect(page.value).toBe(2)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps page within valid range', () => {
|
||||||
|
const items = ref(makeItems(10))
|
||||||
|
const { page, prev, next, goTo } = usePagination(items, { pageSize: 5 })
|
||||||
|
|
||||||
|
prev()
|
||||||
|
expect(page.value).toBe(1)
|
||||||
|
|
||||||
|
next()
|
||||||
|
next()
|
||||||
|
expect(page.value).toBe(2)
|
||||||
|
|
||||||
|
goTo(99)
|
||||||
|
expect(page.value).toBe(2)
|
||||||
|
|
||||||
|
goTo(0)
|
||||||
|
expect(page.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goTo navigates to a specific page', () => {
|
||||||
|
const items = ref(makeItems(50))
|
||||||
|
const { page, paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
goTo(3)
|
||||||
|
expect(page.value).toBe(3)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-21')
|
||||||
|
|
||||||
|
goTo(5)
|
||||||
|
expect(page.value).toBe(5)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-41')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets to page 1 when items change', async () => {
|
||||||
|
const items = ref(makeItems(30))
|
||||||
|
const { page, next } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
next()
|
||||||
|
next()
|
||||||
|
expect(page.value).toBe(3)
|
||||||
|
|
||||||
|
items.value = makeItems(5)
|
||||||
|
await nextTick()
|
||||||
|
expect(page.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets to page 1 when page size changes', async () => {
|
||||||
|
const items = ref(makeItems(50))
|
||||||
|
const { page, next, setPageSize } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
next()
|
||||||
|
next()
|
||||||
|
expect(page.value).toBe(3)
|
||||||
|
|
||||||
|
setPageSize(20)
|
||||||
|
await nextTick()
|
||||||
|
expect(page.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles last page with fewer items', () => {
|
||||||
|
const items = ref(makeItems(23))
|
||||||
|
const { paginatedItems, goTo } = usePagination(items, { pageSize: 10 })
|
||||||
|
|
||||||
|
goTo(3)
|
||||||
|
expect(paginatedItems.value).toHaveLength(3)
|
||||||
|
expect(paginatedItems.value[0]).toBe('item-21')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty items', () => {
|
||||||
|
const items = ref<string[]>([])
|
||||||
|
const { paginatedItems, pageCount, totalItems } = usePagination(items)
|
||||||
|
|
||||||
|
expect(paginatedItems.value).toHaveLength(0)
|
||||||
|
expect(pageCount.value).toBe(1)
|
||||||
|
expect(totalItems.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes totalItems as reactive count', async () => {
|
||||||
|
const items = ref(makeItems(10))
|
||||||
|
const { totalItems } = usePagination(items)
|
||||||
|
|
||||||
|
expect(totalItems.value).toBe(10)
|
||||||
|
|
||||||
|
items.value = makeItems(42)
|
||||||
|
await nextTick()
|
||||||
|
expect(totalItems.value).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults page size to 20', () => {
|
||||||
|
const items = ref(makeItems(50))
|
||||||
|
const { pageSize, paginatedItems } = usePagination(items)
|
||||||
|
|
||||||
|
expect(pageSize.value).toBe(20)
|
||||||
|
expect(paginatedItems.value).toHaveLength(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores invalid page size', () => {
|
||||||
|
const items = ref(makeItems(10))
|
||||||
|
const { pageSize, setPageSize } = usePagination(items, { pageSize: 5 })
|
||||||
|
|
||||||
|
setPageSize(0)
|
||||||
|
expect(pageSize.value).toBe(5)
|
||||||
|
|
||||||
|
setPageSize(-10)
|
||||||
|
expect(pageSize.value).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
71
frontend/src/shared/composables/usePagination.ts
Normal file
71
frontend/src/shared/composables/usePagination.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { ref, computed, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationReturn<T> {
|
||||||
|
page: Ref<number>
|
||||||
|
pageSize: Ref<number>
|
||||||
|
pageCount: Ref<number>
|
||||||
|
paginatedItems: Ref<T[]>
|
||||||
|
totalItems: Ref<number>
|
||||||
|
next: () => void
|
||||||
|
prev: () => void
|
||||||
|
goTo: (target: number) => void
|
||||||
|
setPageSize: (size: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagination<T>(
|
||||||
|
items: Ref<T[]>,
|
||||||
|
options: PaginationOptions = {},
|
||||||
|
): PaginationReturn<T> {
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(options.pageSize ?? 20)
|
||||||
|
|
||||||
|
const totalItems = computed(() => items.value.length)
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value)))
|
||||||
|
|
||||||
|
const paginatedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * pageSize.value
|
||||||
|
return items.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset to page 1 when source data or page size changes
|
||||||
|
watch([items, pageSize], () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function clamp(target: number): number {
|
||||||
|
return Math.max(1, Math.min(target, pageCount.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(target: number): void {
|
||||||
|
page.value = clamp(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function next(): void {
|
||||||
|
goTo(page.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev(): void {
|
||||||
|
goTo(page.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPageSize(size: number): void {
|
||||||
|
if (size > 0) pageSize.value = size
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
paginatedItems,
|
||||||
|
totalItems,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
goTo,
|
||||||
|
setPageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,11 @@ const messages: Messages = {
|
||||||
'chunking.chunking': 'Chunking...',
|
'chunking.chunking': 'Chunking...',
|
||||||
'chunking.chunks': 'chunks',
|
'chunking.chunks': 'chunks',
|
||||||
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
||||||
|
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
'pagination.pageOf': 'Page {current} sur {total}',
|
||||||
|
'pagination.perPage': '/ page',
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
'settings.title': 'Paramètres',
|
'settings.title': 'Paramètres',
|
||||||
|
|
@ -207,6 +212,10 @@ const messages: Messages = {
|
||||||
'chunking.chunking': 'Chunking...',
|
'chunking.chunking': 'Chunking...',
|
||||||
'chunking.chunks': 'chunks',
|
'chunking.chunks': 'chunks',
|
||||||
'chunking.noChunks': 'Run chunking to prepare segments.',
|
'chunking.noChunks': 'Run chunking to prepare segments.',
|
||||||
|
'chunking.noChunksOnPage': 'No chunks on this page.',
|
||||||
|
|
||||||
|
'pagination.pageOf': 'Page {current} of {total}',
|
||||||
|
'pagination.perPage': '/ page',
|
||||||
|
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.apiUrl': 'API URL',
|
'settings.apiUrl': 'API URL',
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,17 @@ export interface ChunkingOptions {
|
||||||
repeat_table_header?: boolean
|
repeat_table_header?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChunkBbox {
|
||||||
|
page: number
|
||||||
|
bbox: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Chunk {
|
export interface Chunk {
|
||||||
text: string
|
text: string
|
||||||
headings: string[]
|
headings: string[]
|
||||||
sourcePage: number | null
|
sourcePage: number | null
|
||||||
tokenCount: number
|
tokenCount: number
|
||||||
|
bboxes: ChunkBbox[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageElement {
|
export interface PageElement {
|
||||||
|
|
|
||||||
135
frontend/src/shared/ui/PaginationBar.vue
Normal file
135
frontend/src/shared/ui/PaginationBar.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="pageCount > 1" class="pagination-bar">
|
||||||
|
<div class="pagination-nav">
|
||||||
|
<button class="pagination-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="pagination-info">
|
||||||
|
{{ t('pagination.pageOf', { current: String(page), total: String(pageCount) }) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="pagination-btn"
|
||||||
|
:disabled="page >= pageCount"
|
||||||
|
@click="$emit('update:page', page + 1)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" class="pagination-icon">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-size">
|
||||||
|
<select
|
||||||
|
:value="pageSize"
|
||||||
|
class="pagination-select"
|
||||||
|
@change="$emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"
|
||||||
|
>
|
||||||
|
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
||||||
|
{{ size }} {{ t('pagination.perPage') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
pageSize: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:page': [value: number]
|
||||||
|
'update:pageSize': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const pageSizeOptions = [5, 10, 20, 50]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-select {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-select:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { default as AppSidebar } from './AppSidebar.vue'
|
export { default as AppSidebar } from './AppSidebar.vue'
|
||||||
|
export { default as PaginationBar } from './PaginationBar.vue'
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ export default defineConfig({
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue