Merge pull request #9 from scub-france/small-fixes

Final fixes before first release
This commit is contained in:
Pier-Jean Malandrino 2026-03-17 17:09:59 +01:00 committed by GitHub
commit fc0c20b3a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 315 additions and 69 deletions

View file

@ -3,7 +3,7 @@
A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling). A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling).
Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser. Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser.
![Docling Studio — Visual Mode](docs/screenshots/visual-mode.png) ![Docling Studio — Execution Result](docs/screenshots/DS-execution-result.png)
## Features ## Features
@ -20,7 +20,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
| Import | Configure | Results | | Import | Configure | Results |
|--------|-----------|---------| |--------|-----------|---------|
| ![Import](docs/screenshots/import.png) | ![Configure](docs/screenshots/configure.png) | ![Results](docs/screenshots/results.png) | | ![Import](docs/screenshots/DS-load-document.png) | ![Configure](docs/screenshots/DS-configure-execution.png) | ![Results](docs/screenshots/DS-execution-result.png) |
</details> </details>
@ -79,7 +79,7 @@ docker compose up --build
Open [http://localhost:3000](http://localhost:3000) Open [http://localhost:3000](http://localhost:3000)
> **Note:** First analysis may take a few minutes as Docling downloads its ML models (~40 MB) on first run. > **Note:** The first analysis takes a bit longer as Docling downloads and caches its ML models (~400 MB). Subsequent runs are fast.
### Local Development ### Local Development
@ -122,13 +122,13 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
## Performance & System Requirements ## Performance & System Requirements
Docling runs ML models (layout analysis, OCR, table structure) on **CPU by default**. Processing time depends on document size and complexity. Docling leverages optimized ML models (layout analysis, OCR, table structure) that run efficiently on CPU. The first analysis takes slightly longer as models are downloaded and cached (~400 MB). Subsequent runs are fast, even on large documents.
| Document type | Pages | Approx. time (CPU) | | Document type | Pages | Approx. time (CPU) |
|---------------|-------|---------------------| |---------------|-------|---------------------|
| Simple report | 5-10 | 1-3 min | | Simple report | 5-10 | ~30s-1 min |
| Research paper | 15-30 | 5-10 min | | Research paper | 10-30 | ~1-2 min |
| Dense PDF with tables | 30+ | 10-20 min | | Large document | 100+ | ~2-5 min |
### Docker Desktop settings ### Docker Desktop settings
@ -144,17 +144,15 @@ The document parser needs **at least 4 GB of RAM**. Recommended Docker Desktop a
### Platform support ### Platform support
All Docker images are **multi-arch** (linux/amd64 + linux/arm64). Works natively on: All Docker images are **multi-arch** (linux/amd64 + linux/arm64). All processing runs on **CPU** — no GPU required.
| Platform | Architecture | GPU acceleration | | Platform | Architecture |
|----------|-------------|-----------------| |----------|-------------|
| **macOS Apple Silicon** (M1/M2/M3) | arm64 | Not in Docker (MPS unavailable). Run parser locally for GPU. | | **macOS Apple Silicon** (M1/M2/M3/M4) | arm64 |
| **macOS Intel** | amd64 | N/A | | **macOS Intel** | amd64 |
| **Linux x86_64** | amd64 | NVIDIA GPU via `docker compose --profile gpu` (coming soon) | | **Linux x86_64** | amd64 |
| **Linux ARM** (Raspberry Pi 5, Ampere) | arm64 | CPU only | | **Linux ARM** (Raspberry Pi 5, Ampere) | arm64 |
| **Windows + WSL2** | amd64 | NVIDIA GPU passthrough supported | | **Windows + WSL2** | amd64 |
> **Tip for Mac users:** For faster processing, run the document parser **locally** (outside Docker) to leverage Apple Silicon's MPS acceleration when supported by PyTorch/Docling.
## Tech Stack ## Tech Stack

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -10,6 +10,9 @@
<script setup> <script setup>
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { AppSidebar } from '../shared/ui/index.js' import { AppSidebar } from '../shared/ui/index.js'
import { useSettingsStore } from '../features/settings/store.js'
useSettingsStore()
</script> </script>
<style> <style>
@ -46,6 +49,29 @@ import { AppSidebar } from '../shared/ui/index.js'
--transition: 150ms ease; --transition: 150ms ease;
} }
html.light {
--bg: #FAFAFA;
--bg-surface: #FFFFFF;
--bg-elevated: #F4F4F5;
--bg-hover: #E4E4E7;
--accent: #F97316;
--accent-hover: #EA580C;
--accent-muted: rgba(249, 115, 22, 0.10);
--text: #18181B;
--text-secondary: #52525B;
--text-muted: #A1A1AA;
--border: #E4E4E7;
--border-light: #D4D4D8;
--success: #16A34A;
--error: #DC2626;
--warning: #CA8A04;
--info: #2563EB;
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="image-gallery"> <div class="image-gallery">
<div v-if="images.length === 0" class="gallery-empty"> <div v-if="images.length === 0" class="gallery-empty">
No images detected in this document {{ t('results.noImages') }}
</div> </div>
<div v-else class="gallery-grid"> <div v-else class="gallery-grid">
<div <div
@ -11,7 +11,7 @@
> >
<div class="card-header"> <div class="card-header">
<span class="card-type">Picture</span> <span class="card-type">Picture</span>
<span class="card-page">Page {{ img.page }}</span> <span class="card-page">{{ t('results.page') }} {{ img.page }}</span>
</div> </div>
<div class="card-content" v-if="img.content"> <div class="card-content" v-if="img.content">
{{ img.content.substring(0, 100) }} {{ img.content.substring(0, 100) }}
@ -27,6 +27,9 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n.js'
const { t } = useI18n()
const props = defineProps({ const props = defineProps({
pages: { type: Array, default: () => [] } pages: { type: Array, default: () => [] }

View file

@ -5,11 +5,13 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import { useI18n } from '../../../shared/i18n.js'
const props = defineProps({ content: String }) const props = defineProps({ content: String })
const { t } = useI18n()
const rendered = computed(() => { const rendered = computed(() => {
if (!props.content) return '<p class="empty">No markdown content</p>' if (!props.content) return `<p class="empty">${t('results.noMarkdown')}</p>`
return marked.parse(props.content) return marked.parse(props.content)
}) })
</script> </script>

View file

@ -14,7 +14,7 @@
<!-- Page chip --> <!-- Page chip -->
<div class="page-indicator" v-if="totalPages > 0"> <div class="page-indicator" v-if="totalPages > 0">
<span class="page-chip">Page {{ currentPage }} sur {{ totalPages }}</span> <span class="page-chip">{{ t('results.pageOf', { current: currentPage, total: totalPages }) }}</span>
</div> </div>
<div class="tab-content"> <div class="tab-content">
@ -27,17 +27,17 @@
</div> </div>
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder"> <div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
<div class="spinner-large" /> <div class="spinner-large" />
<span>Analyse en cours...</span> <span>{{ t('studio.analysisRunning') }}</span>
</div> </div>
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error"> <div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
<span>{{ store.currentAnalysis.errorMessage || 'L\'analyse a échoué' }}</span> <span>{{ store.currentAnalysis.errorMessage || t('results.analysisFailed') }}</span>
</div> </div>
<div v-else class="result-placeholder"> <div v-else class="result-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/> <path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg> </svg>
<span>Lancez une analyse pour voir les résultats</span> <span>{{ t('results.runAnalysis') }}</span>
</div> </div>
</template> </template>
@ -46,19 +46,21 @@ import { ref, computed } from 'vue'
import { useAnalysisStore } from '../store.js' import { useAnalysisStore } from '../store.js'
import MarkdownViewer from './MarkdownViewer.vue' import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue' import ImageGallery from './ImageGallery.vue'
import { useI18n } from '../../../shared/i18n.js'
const props = defineProps({ const props = defineProps({
currentPage: { type: Number, default: 1 } currentPage: { type: Number, default: 1 }
}) })
const store = useAnalysisStore() const store = useAnalysisStore()
const { t } = useI18n()
const activeTab = ref('text') const activeTab = ref('text')
const tabs = [ const tabs = computed(() => [
{ id: 'text', label: 'Résultat du texte' }, { id: 'text', label: t('results.textResult') },
{ id: 'markdown', label: 'Markdown' }, { id: 'markdown', label: t('results.markdown') },
{ id: 'images', label: 'Images' } { id: 'images', label: t('results.images') }
] ])
const totalPages = computed(() => store.currentPages.length) const totalPages = computed(() => store.currentPages.length)

View file

@ -10,14 +10,14 @@
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" /> <input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
<div v-if="store.uploading" class="upload-state"> <div v-if="store.uploading" class="upload-state">
<div class="spinner" /> <div class="spinner" />
<span>Uploading...</span> <span>{{ t('upload.uploading') }}</span>
</div> </div>
<div v-else class="upload-state"> <div v-else class="upload-state">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" /> <path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg> </svg>
<span class="upload-text">Drop a PDF here or click to upload</span> <span class="upload-text">{{ t('upload.drop') }}</span>
<span class="upload-hint">Max 50MB</span> <span class="upload-hint">{{ t('upload.maxSize') }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -25,8 +25,10 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useDocumentStore } from '../store.js' import { useDocumentStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useDocumentStore() const store = useDocumentStore()
const { t } = useI18n()
const fileInput = ref(null) const fileInput = ref(null)
const dragging = ref(false) const dragging = ref(false)

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="history-list"> <div class="history-list">
<div v-if="store.analyses.length === 0" class="history-empty"> <div v-if="store.analyses.length === 0" class="history-empty">
No analyses yet. Go to Studio to analyze your first document. {{ t('history.empty') }}
</div> </div>
<div v-else class="history-items"> <div v-else class="history-items">
<div <div
@ -31,8 +31,10 @@
<script setup> <script setup>
import { useHistoryStore } from '../store.js' import { useHistoryStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useHistoryStore() const store = useHistoryStore()
const { t } = useI18n()
function statusClass(status) { function statusClass(status) {
return { return {

View file

@ -1,8 +1,20 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, watch, watchEffect } from 'vue'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const apiUrl = ref('http://localhost:8000') const apiUrl = ref('http://localhost:8000')
const theme = ref(localStorage.getItem('docling-theme') || 'dark')
const locale = ref(localStorage.getItem('docling-locale') || 'fr')
return { apiUrl } watch(theme, (v) => localStorage.setItem('docling-theme', v))
watch(locale, (v) => localStorage.setItem('docling-locale', v))
watchEffect(() => {
document.documentElement.classList.toggle('light', theme.value === 'light')
})
function setTheme(t) { theme.value = t }
function setLocale(l) { locale.value = l }
return { apiUrl, theme, locale, setTheme, setLocale }
}) })

View file

@ -1,11 +1,27 @@
<template> <template>
<div class="settings-panel"> <div class="settings-panel">
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">API URL</label> <label class="setting-label">{{ t('settings.theme') }}</label>
<input class="setting-input" v-model="store.apiUrl" readonly /> <div class="setting-toggle">
<button :class="{ active: store.theme === 'dark' }" @click="store.setTheme('dark')">
{{ t('settings.themeDark') }}
</button>
<button :class="{ active: store.theme === 'light' }" @click="store.setTheme('light')">
{{ t('settings.themeLight') }}
</button>
</div>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">Version</label> <label class="setting-label">{{ t('settings.language') }}</label>
<div class="setting-toggle">
<button :class="{ active: store.locale === 'fr' }" @click="store.setLocale('fr')">FR</button>
<button :class="{ active: store.locale === 'en' }" @click="store.setLocale('en')">EN</button>
</div>
</div>
<div class="setting-group">
<label class="setting-label">{{ t('settings.version') }}</label>
<span class="setting-value">0.1.0</span> <span class="setting-value">0.1.0</span>
</div> </div>
</div> </div>
@ -13,7 +29,10 @@
<script setup> <script setup>
import { useSettingsStore } from '../store.js' import { useSettingsStore } from '../store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useSettingsStore() const store = useSettingsStore()
const { t } = useI18n()
</script> </script>
<style scoped> <style scoped>
@ -38,6 +57,36 @@ const store = useSettingsStore()
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.setting-toggle {
display: flex;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.setting-toggle button {
flex: 1;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition);
}
.setting-toggle button:hover {
color: var(--text);
background: var(--bg-hover);
}
.setting-toggle button.active {
background: var(--accent-muted);
color: var(--accent);
}
.setting-input { .setting-input {
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="history-page"> <div class="history-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Analysis History</h1> <h1 class="page-title">{{ t('history.title') }}</h1>
</div> </div>
<div class="page-content"> <div class="page-content">
<HistoryList /> <HistoryList />
@ -12,8 +12,10 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { HistoryList, useHistoryStore } from '../features/history/index.js' import { HistoryList, useHistoryStore } from '../features/history/index.js'
import { useI18n } from '../shared/i18n.js'
const store = useHistoryStore() const store = useHistoryStore()
const { t } = useI18n()
onMounted(() => store.load()) onMounted(() => store.load())
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="settings-page"> <div class="settings-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Settings</h1> <h1 class="page-title">{{ t('settings.title') }}</h1>
</div> </div>
<div class="page-content"> <div class="page-content">
<SettingsPanel /> <SettingsPanel />
@ -11,6 +11,9 @@
<script setup> <script setup>
import { SettingsPanel } from '../features/settings/index.js' import { SettingsPanel } from '../features/settings/index.js'
import { useI18n } from '../shared/i18n.js'
const { t } = useI18n()
</script> </script>
<style scoped> <style scoped>

View file

@ -5,11 +5,11 @@
<div class="import-logo"> <div class="import-logo">
<span class="logo-icon">D</span> <span class="logo-icon">D</span>
</div> </div>
<h1 class="import-title">Intelligence des documents</h1> <h1 class="import-title">{{ t('studio.title') }}</h1>
<p class="import-subtitle">Importez un document PDF pour commencer l'analyse avec Docling</p> <p class="import-subtitle">{{ t('studio.subtitle') }}</p>
<DocumentUpload /> <DocumentUpload />
<div class="import-docs" v-if="documentStore.documents.length"> <div class="import-docs" v-if="documentStore.documents.length">
<label class="section-label">Documents r&eacute;cents</label> <label class="section-label">{{ t('studio.recentDocs') }}</label>
<DocumentList /> <DocumentList />
</div> </div>
</div> </div>
@ -20,25 +20,25 @@
<!-- Top bar --> <!-- Top bar -->
<div class="studio-topbar"> <div class="studio-topbar">
<div class="topbar-left"> <div class="topbar-left">
<h1 class="topbar-title">Intelligence des documents</h1> <h1 class="topbar-title">{{ t('studio.title') }}</h1>
<div class="mode-toggle"> <div class="mode-toggle">
<button <button
class="toggle-btn" class="toggle-btn"
:class="{ active: mode === 'configurer' }" :class="{ active: mode === 'configurer' }"
@click="mode = 'configurer'" @click="mode = 'configurer'"
>Configurer</button> >{{ t('studio.configure') }}</button>
<button <button
class="toggle-btn" class="toggle-btn"
:class="{ active: mode === 'verifier' }" :class="{ active: mode === 'verifier' }"
@click="mode = 'verifier'" @click="mode = 'verifier'"
:disabled="!analysisStore.currentAnalysis" :disabled="!analysisStore.currentAnalysis"
>V&eacute;rifier</button> >{{ t('studio.verify') }}</button>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button class="topbar-btn" @click="addMore"> <button class="topbar-btn" @click="addMore">
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/></svg>
Ajouter des fichiers {{ t('studio.addFiles') }}
</button> </button>
<button <button
class="topbar-btn primary" class="topbar-btn primary"
@ -48,7 +48,7 @@
> >
<div v-if="analysisStore.running" class="spinner-sm" /> <div v-if="analysisStore.running" class="spinner-sm" />
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg> <svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
{{ analysisStore.running ? 'Analyse...' : 'Ex\u00e9cuter' }} {{ analysisStore.running ? t('studio.analyzing') : t('studio.run') }}
</button> </button>
</div> </div>
</div> </div>
@ -58,7 +58,7 @@
<div class="doc-info-left"> <div class="doc-info-left">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg> <svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>
<span class="doc-filename">{{ selectedDoc.filename }}</span> <span class="doc-filename">{{ selectedDoc.filename }}</span>
<span class="doc-status-chip loaded">Charg&eacute;</span> <span class="doc-status-chip loaded">{{ t('studio.loaded') }}</span>
</div> </div>
<div class="doc-info-right" v-if="analysisStore.currentAnalysis"> <div class="doc-info-right" v-if="analysisStore.currentAnalysis">
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'COMPLETED'"> <span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'COMPLETED'">
@ -67,11 +67,11 @@
</span> </span>
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'"> <span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
<div class="spinner-xs" /> <div class="spinner-xs" />
Analyse en cours... {{ t('studio.analysisRunning') }}
</span> </span>
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'"> <span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
<span class="info-dot error" /> <span class="info-dot error" />
&Eacute;chec {{ t('studio.failed') }}
</span> </span>
</div> </div>
</div> </div>
@ -108,7 +108,7 @@
@click="visualMode = !visualMode" @click="visualMode = !visualMode"
> >
<svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg> <svg viewBox="0 0 20 20" fill="currentColor" class="btn-icon"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
Visuel {{ t('studio.visual') }}
</button> </button>
</template> </template>
</div> </div>
@ -138,7 +138,7 @@
<div v-if="mode === 'configurer'" class="config-panel"> <div v-if="mode === 'configurer'" class="config-panel">
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">
Mod&egrave;le {{ t('config.model') }}
<span class="config-hint">?</span> <span class="config-hint">?</span>
</label> </label>
<div class="config-select-display"> <div class="config-select-display">
@ -149,26 +149,26 @@
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">
Pages {{ t('config.pages') }}
<span class="config-hint">?</span> <span class="config-hint">?</span>
</label> </label>
<input type="text" class="config-input" placeholder='par ex. "1-4,8"' v-model="pageRange" /> <input type="text" class="config-input" :placeholder="t('config.pagesPlaceholder')" v-model="pageRange" />
</div> </div>
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">
Extraire les tableaux {{ t('config.extractTables') }}
<span class="config-hint">?</span> <span class="config-hint">?</span>
</label> </label>
<select class="config-select" v-model="tableMode"> <select class="config-select" v-model="tableMode">
<option value="markdown">Markdown int&eacute;gr&eacute;</option> <option value="markdown">{{ t('config.markdownIntegrated') }}</option>
<option value="html">HTML</option> <option value="html">HTML</option>
<option value="csv">CSV</option> <option value="csv">CSV</option>
</select> </select>
</div> </div>
<div class="config-section"> <div class="config-section">
<label class="config-label">Extraire</label> <label class="config-label">{{ t('config.extract') }}</label>
<div class="extract-options"> <div class="extract-options">
<button <button
v-for="opt in extractOptions" v-for="opt in extractOptions"
@ -187,15 +187,15 @@
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">
Annoter les images {{ t('config.annotateImages') }}
<span class="config-hint">?</span> <span class="config-hint">?</span>
</label> </label>
<button class="config-add-btn">Ajouter +</button> <button class="config-add-btn">{{ t('config.add') }}</button>
</div> </div>
<!-- Documents list at bottom --> <!-- Documents list at bottom -->
<div class="config-section config-docs"> <div class="config-section config-docs">
<label class="config-label">Documents</label> <label class="config-label">{{ t('config.documents') }}</label>
<DocumentList /> <DocumentList />
</div> </div>
</div> </div>
@ -217,9 +217,11 @@ import { DocumentUpload, DocumentList } from '../features/document/index.js'
import { ResultTabs } from '../features/analysis/index.js' import { ResultTabs } from '../features/analysis/index.js'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { getPreviewUrl } from '../features/document/api.js' import { getPreviewUrl } from '../features/document/api.js'
import { useI18n } from '../shared/i18n.js'
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore() const analysisStore = useAnalysisStore()
const { t } = useI18n()
const mode = ref('configurer') const mode = ref('configurer')
const currentPage = ref(1) const currentPage = ref(1)
@ -242,11 +244,11 @@ function onPdfImageLoad() {
nextTick(() => bboxOverlayRef.value?.draw()) nextTick(() => bboxOverlayRef.value?.draw())
} }
const extractOptions = [ const extractOptions = computed(() => [
{ id: 'images', label: 'Images', icon: 'image' }, { id: 'images', label: t('config.images'), icon: 'image' },
{ id: 'header', label: 'En-t\u00eate', icon: 'header' }, { id: 'header', label: t('config.header'), icon: 'header' },
{ id: 'footer', label: 'Pied de page', icon: 'footer' } { id: 'footer', label: t('config.footer'), icon: 'footer' }
] ])
const activeExtracts = reactive(new Set(['images'])) const activeExtracts = reactive(new Set(['images']))
function toggleExtract(id) { function toggleExtract(id) {

140
frontend/src/shared/i18n.js Normal file
View file

@ -0,0 +1,140 @@
import { useSettingsStore } from '../features/settings/store.js'
const messages = {
fr: {
// Sidebar
'nav.studio': 'Studio',
'nav.history': 'Historique',
'nav.settings': 'Paramètres',
// Studio — import
'studio.title': 'Intelligence des documents',
'studio.subtitle': 'Importez un document PDF pour commencer l\'analyse avec Docling',
'studio.recentDocs': 'Documents récents',
// Studio — workspace
'studio.configure': 'Configurer',
'studio.verify': 'Vérifier',
'studio.addFiles': 'Ajouter des fichiers',
'studio.analyzing': 'Analyse...',
'studio.run': 'Exécuter',
'studio.loaded': 'Chargé',
'studio.analysisRunning': 'Analyse en cours...',
'studio.failed': 'Échec',
'studio.visual': 'Visuel',
// Config panel
'config.model': 'Modèle',
'config.pages': 'Pages',
'config.pagesPlaceholder': 'par ex. "1-4,8"',
'config.extractTables': 'Extraire les tableaux',
'config.markdownIntegrated': 'Markdown intégré',
'config.extract': 'Extraire',
'config.images': 'Images',
'config.header': 'En-tête',
'config.footer': 'Pied de page',
'config.annotateImages': 'Annoter les images',
'config.add': 'Ajouter +',
'config.documents': 'Documents',
// Results
'results.textResult': 'Résultat du texte',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.pageOf': 'Page {current} sur {total}',
'results.noImages': 'Aucune image détectée dans ce document',
'results.noMarkdown': 'Pas de contenu markdown',
'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
'results.analysisFailed': 'L\'analyse a échoué',
'results.page': 'Page',
// Upload
'upload.drop': 'Déposez un PDF ici ou cliquez pour importer',
'upload.uploading': 'Import en cours...',
'upload.maxSize': 'Max 50Mo',
// History
'history.title': 'Historique des analyses',
'history.empty': 'Aucune analyse. Allez dans Studio pour analyser votre premier document.',
// Settings
'settings.title': 'Paramètres',
'settings.apiUrl': 'API URL',
'settings.version': 'Version',
'settings.theme': 'Thème',
'settings.themeDark': 'Sombre',
'settings.themeLight': 'Clair',
'settings.language': 'Langue',
},
en: {
'nav.studio': 'Studio',
'nav.history': 'History',
'nav.settings': 'Settings',
'studio.title': 'Document Intelligence',
'studio.subtitle': 'Upload a PDF document to start analyzing with Docling',
'studio.recentDocs': 'Recent documents',
'studio.configure': 'Configure',
'studio.verify': 'Verify',
'studio.addFiles': 'Add files',
'studio.analyzing': 'Analyzing...',
'studio.run': 'Run',
'studio.loaded': 'Loaded',
'studio.analysisRunning': 'Analysis running...',
'studio.failed': 'Failed',
'studio.visual': 'Visual',
'config.model': 'Model',
'config.pages': 'Pages',
'config.pagesPlaceholder': 'e.g. "1-4,8"',
'config.extractTables': 'Extract tables',
'config.markdownIntegrated': 'Inline Markdown',
'config.extract': 'Extract',
'config.images': 'Images',
'config.header': 'Header',
'config.footer': 'Footer',
'config.annotateImages': 'Annotate images',
'config.add': 'Add +',
'config.documents': 'Documents',
'results.textResult': 'Text result',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.pageOf': 'Page {current} of {total}',
'results.noImages': 'No images detected in this document',
'results.noMarkdown': 'No markdown content',
'results.runAnalysis': 'Run an analysis to see results',
'results.analysisFailed': 'Analysis failed',
'results.page': 'Page',
'upload.drop': 'Drop a PDF here or click to upload',
'upload.uploading': 'Uploading...',
'upload.maxSize': 'Max 50MB',
'history.title': 'Analysis History',
'history.empty': 'No analyses yet. Go to Studio to analyze your first document.',
'settings.title': 'Settings',
'settings.apiUrl': 'API URL',
'settings.version': 'Version',
'settings.theme': 'Theme',
'settings.themeDark': 'Dark',
'settings.themeLight': 'Light',
'settings.language': 'Language',
}
}
export function useI18n() {
const settings = useSettingsStore()
function t(key, params = {}) {
let str = messages[settings.locale]?.[key] || messages['fr'][key] || key
for (const [k, v] of Object.entries(params)) {
str = str.replace(`{${k}}`, v)
}
return str
}
return { t }
}

View file

@ -10,17 +10,17 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'studio' }"> <RouterLink to="/" class="nav-item" :class="{ active: route.name === 'studio' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg>
<span>Studio</span> <span>{{ t('nav.studio') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }"> <RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
<span>History</span> <span>{{ t('nav.history') }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }"> <RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg> <svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
<span>Settings</span> <span>{{ t('nav.settings') }}</span>
</RouterLink> </RouterLink>
</nav> </nav>
@ -32,7 +32,10 @@
<script setup> <script setup>
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from '../i18n.js'
const route = useRoute() const route = useRoute()
const { t } = useI18n()
</script> </script>
<style scoped> <style scoped>