mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 19:17:13 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfaf270f83 | ||
|
|
706504cac0 | ||
|
|
8878f7bf1f | ||
|
|
5d58193a5d | ||
|
|
d479d07e05 | ||
|
|
ce45104cd5 | ||
|
|
ac5d7de9cc | ||
|
|
560d1e53ed | ||
|
|
37b3d7240c | ||
|
|
4f6907ba6f | ||
|
|
c55f37e980 | ||
|
|
90d6bbb4a9 | ||
|
|
5f7dc04657 | ||
|
|
ebae55f158 | ||
|
|
1c64328b71 | ||
|
|
962b26879d | ||
|
|
6d6cddca16 | ||
|
|
ef5237620b | ||
|
|
82cfe868b5 | ||
|
|
e557854d8c |
No files matched your search
@@ -38,6 +38,7 @@ src/documents/bulk_edit.py:0: error: Incompatible types in assignment (expressio
|
|||||||
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
||||||
|
src/documents/bulk_edit.py:0: error: Missing named argument "p" for "remove" of "PageList" [call-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
||||||
|
|||||||
@@ -91,6 +91,13 @@
|
|||||||
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
||||||
"severity": "error"
|
"severity": "error"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column": 33,
|
||||||
|
"path": "src/documents/bulk_edit.py",
|
||||||
|
"name": "missing-argument",
|
||||||
|
"concise_description": "Missing argument `p` in function `pikepdf._core.PageList.remove`",
|
||||||
|
"severity": "error"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column": 25,
|
"column": 25,
|
||||||
"path": "src/documents/caching.py",
|
"path": "src/documents/caching.py",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
|
"marked": "~18.0.14",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
|
|||||||
Generated
+10
@@ -53,6 +53,9 @@ importers:
|
|||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.18.1
|
specifier: ^4.18.1
|
||||||
version: 4.18.1
|
version: 4.18.1
|
||||||
|
marked:
|
||||||
|
specifier: ~18.0.14
|
||||||
|
version: 18.0.14
|
||||||
mime-names:
|
mime-names:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -3226,6 +3229,11 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
|
marked@18.0.14:
|
||||||
|
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
material-colors@1.2.6:
|
material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
|
||||||
@@ -7409,6 +7417,8 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
|
marked@18.0.14: {}
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
material-colors@1.2.6: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|||||||
@@ -1,49 +1,85 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||||
@for (message of messages(); track message) {
|
<span class="fw-semibold small">
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
@if (documentId()) {
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<ng-container i18n>Ask about this document</ng-container>
|
||||||
<span class="text-break">
|
} @else {
|
||||||
{{ message.content }}
|
<ng-container i18n>Ask about your documents</ng-container>
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
|
||||||
</span>
|
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
|
||||||
<div class="chat-references list-group mt-3">
|
|
||||||
@for (reference of message.references; track reference.id) {
|
|
||||||
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div #scrollAnchor></div>
|
</span>
|
||||||
</div>
|
@if (messages().length) {
|
||||||
|
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<div class="chat-messages small px-2">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
@if (!messages().length) {
|
||||||
<div class="input-group">
|
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||||
<input
|
}
|
||||||
#chatInput
|
@for (message of messages(); track message) {
|
||||||
id="chatInput"
|
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||||
|
@if (message.isStreaming && !message.content) {
|
||||||
|
<div class="d-flex align-items-center text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (slowResponse()) {
|
||||||
|
<ng-container i18n>Still thinking...</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Thinking...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (message.role === 'assistant') {
|
||||||
|
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-break">{{ message.content }}</span>
|
||||||
|
}
|
||||||
|
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||||
|
<div class="chat-references list-group mt-3">
|
||||||
|
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||||
|
@for (reference of message.references; track reference.id) {
|
||||||
|
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div #scrollAnchor></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="chat-input border-top p-2">
|
||||||
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
#chatInput
|
||||||
|
id="chatInput"
|
||||||
|
class="form-control" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading()"
|
[disabled]="loading()"
|
||||||
[ngModel]="input()"
|
[ngModel]="input()"
|
||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
@if (loading()) {
|
||||||
</div>
|
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||||
</form>
|
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||||
</div>
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: var(--pngx-toast-max-width);
|
width: min(36rem, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: 350px;
|
max-height: min(60dvh, 36rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-references {
|
|
||||||
font-family: var(--bs-font-sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -20,23 +16,44 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
:host ::ng-deep .dropdown-menu-end {
|
.chat-messages {
|
||||||
right: -3rem;
|
max-height: calc(100dvh - 12rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blinking-cursor {
|
.chat-markdown.streaming ::ng-deep {
|
||||||
font-weight: bold;
|
> :last-child:not(ul, ol, table)::after,
|
||||||
font-size: 1.2em;
|
> :is(ul, ol):last-child > li:last-child::after {
|
||||||
animation: blink 1s step-end infinite;
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 2px;
|
||||||
|
background-color: currentColor;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
from, to {
|
50% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
50% {
|
}
|
||||||
opacity: 1;
|
|
||||||
|
.chat-markdown ::ng-deep {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
||||||
|
expect(messagesEl.textContent).toContain('Thinking...')
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
|
|||||||
await fixture.whenStable()
|
await fixture.whenStable()
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
||||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
|
|||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||||
)
|
)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
||||||
|
|
||||||
|
mockStream$.complete()
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Partial')
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toContain(
|
expect(component.messages()[1].content).toBe(
|
||||||
'⚠️ Error receiving response.'
|
'Partial\n\n⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep streaming until the typewriter catches up', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
mockStream$.complete()
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(true)
|
||||||
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a slow response message while waiting', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(8000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.textContent).toContain('Still thinking...')
|
||||||
|
|
||||||
|
mockStream$.next('Hi')
|
||||||
|
expect(component.slowResponse()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop streaming and keep the partial response', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||||
|
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
jest.advanceTimersByTime(20)
|
||||||
|
stopButton.click()
|
||||||
|
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop the empty response when stopped before any content', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
component.stopStreaming()
|
||||||
|
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
component.stopStreaming() // no-op once stopped
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the conversation', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||||
|
expect(component.messages()).toHaveLength(0)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest.advanceTimersByTime(10)
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
fixture.nativeElement.querySelector('#chatInput')
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Answers come from your documents'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render markdown for assistant messages only', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{ role: 'user', content: '**user**' },
|
||||||
|
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
||||||
|
'assistant'
|
||||||
|
)
|
||||||
|
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||||
|
expect(el.textContent).toContain('**user**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'[View original document](https://otherdomain.example/?data=secret)',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
const link = el.querySelector('.chat-markdown a')
|
||||||
|
expect(link?.textContent).toBe('View original document')
|
||||||
|
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -10,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { filter, map } from 'rxjs'
|
import { filter, map, Subscription } from 'rxjs'
|
||||||
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
|
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -25,6 +29,7 @@ import {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
MarkdownPipe,
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
|
readonly slowResponse = signal(false)
|
||||||
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
private typewriterTimeout: ReturnType<typeof setTimeout>
|
||||||
|
private streamSubscription: Subscription
|
||||||
|
private streamingMessage: ChatMessage
|
||||||
|
private streamFinished = false
|
||||||
|
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
|
this.streamingMessage = assistantMessage
|
||||||
|
this.streamFinished = false
|
||||||
|
this.slowResponseTimeout = setTimeout(
|
||||||
|
() => this.slowResponse.set(true),
|
||||||
|
SLOW_RESPONSE_DELAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
this.streamSubscription = this.chatService
|
||||||
next: (chunk) => {
|
.streamChat(this.documentId(), this.input())
|
||||||
const nextResponse = parseChatResponse(chunk)
|
.subscribe({
|
||||||
|
next: (chunk) => {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
} else {
|
} else {
|
||||||
const visibleDelta = nextResponse.content.substring(
|
const visibleDelta = nextResponse.content.substring(
|
||||||
lastVisibleContent.length
|
lastVisibleContent.length
|
||||||
)
|
)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
this.flushTypewriter(assistantMessage)
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
this.notifyMessagesChanged()
|
this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
},
|
complete: () => {
|
||||||
complete: () => {
|
// let the typewriter catch up before hiding the cursor
|
||||||
assistantMessage.isStreaming = false
|
this.streamFinished = true
|
||||||
this.notifyMessagesChanged()
|
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
this.scrollToBottom()
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
stopStreaming(): void {
|
||||||
|
const message = this.streamingMessage
|
||||||
|
if (!message?.isStreaming) return
|
||||||
|
|
||||||
|
this.streamSubscription?.unsubscribe()
|
||||||
|
this.flushTypewriter(message)
|
||||||
|
if (!message.content) {
|
||||||
|
this.messages.update((messages) => messages.filter((m) => m !== message))
|
||||||
|
}
|
||||||
|
this.finishStreaming(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChat(): void {
|
||||||
|
this.stopStreaming()
|
||||||
|
this.messages.set([])
|
||||||
|
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishStreaming(message: ChatMessage): void {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
this.streamFinished = false
|
||||||
|
message.isStreaming = false
|
||||||
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSlowResponse(): void {
|
||||||
|
clearTimeout(this.slowResponseTimeout)
|
||||||
|
this.slowResponse.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushTypewriter(message: ChatMessage): void {
|
||||||
|
message.content += this.typewriterBuffer.join('')
|
||||||
|
this.cancelTypewriter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelTypewriter(): void {
|
||||||
|
clearTimeout(this.typewriterTimeout)
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
|
this.cancelTypewriter()
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
if (this.streamFinished) this.finishStreaming(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
// 10ms per character
|
||||||
|
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { MarkdownPipe } from './markdown.pipe'
|
||||||
|
|
||||||
|
describe('MarkdownPipe', () => {
|
||||||
|
const pipe = new MarkdownPipe()
|
||||||
|
|
||||||
|
it('should return empty string for empty input', () => {
|
||||||
|
expect(pipe.transform(null)).toEqual('')
|
||||||
|
expect(pipe.transform(undefined)).toEqual('')
|
||||||
|
expect(pipe.transform('')).toEqual('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render basic markdown', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
||||||
|
)
|
||||||
|
expect(html).toContain('<strong>bold</strong>')
|
||||||
|
expect(html).toContain('<em>em</em>')
|
||||||
|
expect(html).toContain('<code>code</code>')
|
||||||
|
expect(html).toContain('<li>one</li>')
|
||||||
|
expect(html).toContain('<table>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape raw html', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('<script')
|
||||||
|
expect(html).not.toContain('<b>')
|
||||||
|
expect(html).toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render images', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
''
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('otherdomain.example')
|
||||||
|
expect(html).toContain('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render safe links with target and rel', () => {
|
||||||
|
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||||
|
expect(html).toContain(
|
||||||
|
'<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">docs</a>'
|
||||||
|
)
|
||||||
|
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
||||||
|
'href="mailto:a@b.c"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop unsafe or relative links but keep their text', () => {
|
||||||
|
for (const href of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
'vbscript:msgbox',
|
||||||
|
'/api/documents/',
|
||||||
|
]) {
|
||||||
|
const html = pipe.transform(`[click](${href})`)
|
||||||
|
expect(html).not.toContain('<a')
|
||||||
|
expect(html).toContain('click')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show the actual hostname beside web links with misleading labels', () => {
|
||||||
|
for (const protocol of ['http', 'https']) {
|
||||||
|
const html = pipe.transform(
|
||||||
|
`[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
|
||||||
|
)
|
||||||
|
expect(html).toContain(
|
||||||
|
'</a> <span class="text-muted small">(otherdomain.example)</span>'
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('(docs.paperless-ngx.com)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should omit the hostname label for mailto links', () => {
|
||||||
|
expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
|
||||||
|
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:a@b.c">mail</a></p>\n'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape link titles', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||||
|
)
|
||||||
|
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { Marked, Renderer, Tokens } from 'marked'
|
||||||
|
|
||||||
|
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
||||||
|
|
||||||
|
function allowedUrl(href: string): URL | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(href)
|
||||||
|
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||||
|
// absolute links. All escaping is done by marked's default renderer, and
|
||||||
|
// Angular's sanitizer runs on top of this as well.
|
||||||
|
const renderer: Partial<Renderer> = {
|
||||||
|
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
||||||
|
return this.text({ type: 'text', raw, text })
|
||||||
|
},
|
||||||
|
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
||||||
|
return this.text({ type: 'text', raw, text })
|
||||||
|
},
|
||||||
|
link(this: Renderer, token: Tokens.Link): string {
|
||||||
|
const url = allowedUrl(token.href)
|
||||||
|
if (!url) return this.parser.parseInline(token.tokens)
|
||||||
|
const anchor = Renderer.prototype.link
|
||||||
|
.call(this, token)
|
||||||
|
.replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
|
||||||
|
if (!url.hostname) return anchor
|
||||||
|
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
|
||||||
|
return `${anchor} <span class="text-muted small">(${hostname})</span>`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = new Marked({
|
||||||
|
async: false,
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
renderer,
|
||||||
|
})
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'markdown',
|
||||||
|
})
|
||||||
|
export class MarkdownPipe implements PipeTransform {
|
||||||
|
transform(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return markdown.parse(value) as string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
|
arrowReturnLeft,
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
@@ -283,6 +284,7 @@ const icons = {
|
|||||||
arrowDownUp,
|
arrowDownUp,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
arrowRepeat,
|
arrowRepeat,
|
||||||
|
arrowReturnLeft,
|
||||||
arrowRight,
|
arrowRight,
|
||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
|
|||||||
+219
-184
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -24,7 +23,6 @@ from django.db.models import When
|
|||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from documents import pdf_ops
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
@@ -125,11 +123,6 @@ def _resolve_root_and_source_doc(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scratch_path(name: str) -> Path:
|
|
||||||
"""A path inside a fresh directory under SCRATCH_DIR."""
|
|
||||||
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
|
||||||
|
|
||||||
|
|
||||||
def set_correspondent(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
@@ -506,6 +499,8 @@ def rotate(
|
|||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
for pair in docs_by_root_id.values():
|
for pair in docs_by_root_id.values():
|
||||||
if pair.source_doc.mime_type != "application/pdf":
|
if pair.source_doc.mime_type != "application/pdf":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -518,7 +513,11 @@ def rotate(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_rotated.pdf"
|
/ f"{pair.root_doc.id}_rotated.pdf"
|
||||||
)
|
)
|
||||||
pdf_ops.rotate_pdf(pair.source_doc.source_path, filepath, degrees)
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
page.rotate(degrees, relative=True)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
# Preserve metadata/permissions via overrides; mark as new version
|
# Preserve metadata/permissions via overrides; mark as new version
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -561,45 +560,48 @@ def merge(
|
|||||||
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
|
||||||
docs_by_id = {doc.id: doc for doc in qs}
|
docs_by_id = {doc.id: doc for doc in qs}
|
||||||
affected_docs: list[int] = []
|
affected_docs: list[int] = []
|
||||||
handoff_asn: int | None = None
|
import pikepdf
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
# use doc_ids to preserve order
|
|
||||||
for doc_id in doc_ids:
|
|
||||||
doc = docs_by_id.get(doc_id)
|
|
||||||
if doc is None:
|
|
||||||
continue
|
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
|
||||||
try:
|
|
||||||
# archive_path is None when there is no archive version
|
|
||||||
archive_path = (
|
|
||||||
pair.source_doc.archive_path
|
|
||||||
if archive_fallback
|
|
||||||
and pair.source_doc.mime_type != "application/pdf"
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
merger.add(
|
|
||||||
archive_path
|
|
||||||
if archive_path is not None
|
|
||||||
else pair.source_doc.source_path,
|
|
||||||
)
|
|
||||||
affected_docs.append(doc.id)
|
|
||||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
|
||||||
handoff_asn = doc.archive_serial_number
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
|
||||||
)
|
|
||||||
if len(affected_docs) == 0:
|
|
||||||
logger.warning("No documents were merged")
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
filepath = (
|
merged_pdf = pikepdf.new()
|
||||||
Path(
|
version: str = merged_pdf.pdf_version
|
||||||
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
handoff_asn: int | None = None
|
||||||
|
# use doc_ids to preserve order
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
doc = docs_by_id.get(doc_id)
|
||||||
|
if doc is None:
|
||||||
|
continue
|
||||||
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
try:
|
||||||
|
doc_path = (
|
||||||
|
pair.source_doc.archive_path
|
||||||
|
if archive_fallback
|
||||||
|
and pair.source_doc.mime_type != "application/pdf"
|
||||||
|
and pair.source_doc.has_archive_version
|
||||||
|
else pair.source_doc.source_path
|
||||||
)
|
)
|
||||||
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
with pikepdf.open(str(doc_path)) as pdf:
|
||||||
|
version = max(version, pdf.pdf_version)
|
||||||
|
merged_pdf.pages.extend(pdf.pages)
|
||||||
|
affected_docs.append(doc.id)
|
||||||
|
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||||
|
handoff_asn = doc.archive_serial_number
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
|
)
|
||||||
|
if len(affected_docs) == 0:
|
||||||
|
logger.warning("No documents were merged")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
filepath = (
|
||||||
|
Path(
|
||||||
|
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
||||||
)
|
)
|
||||||
merger.save(filepath)
|
/ f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
|
||||||
|
)
|
||||||
|
merged_pdf.remove_unreferenced_resources()
|
||||||
|
merged_pdf.save(filepath, min_version=version)
|
||||||
|
merged_pdf.close()
|
||||||
|
|
||||||
if metadata_document_id:
|
if metadata_document_id:
|
||||||
metadata_document = qs.get(id=metadata_document_id)
|
metadata_document = qs.get(id=metadata_document_id)
|
||||||
@@ -775,60 +777,64 @@ def split(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
outputs = [
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
(
|
for idx, split_doc in enumerate(pages):
|
||||||
[pdf_ops.PageSpec(page) for page in split_doc],
|
dst: pikepdf.Pdf = pikepdf.new()
|
||||||
partial(_scratch_path, f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"),
|
for page in split_doc:
|
||||||
)
|
dst.pages.append(pdf.pages[page - 1])
|
||||||
for split_doc in pages
|
filepath: Path = (
|
||||||
]
|
Path(
|
||||||
filepaths = pdf_ops.build_pdfs(pair.source_doc.source_path, outputs)
|
tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
|
||||||
|
)
|
||||||
|
/ f"{doc.id}_{split_doc[0]}-{split_doc[-1]}.pdf"
|
||||||
|
)
|
||||||
|
dst.remove_unreferenced_resources()
|
||||||
|
dst.save(filepath)
|
||||||
|
dst.close()
|
||||||
|
|
||||||
for idx, (split_doc, filepath) in enumerate(
|
overrides: DocumentMetadataOverrides = (
|
||||||
zip(pages, filepaths, strict=True),
|
DocumentMetadataOverrides().from_document(doc)
|
||||||
):
|
)
|
||||||
overrides: DocumentMetadataOverrides = (
|
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||||
DocumentMetadataOverrides().from_document(doc)
|
if user is not None:
|
||||||
)
|
overrides.owner_id = user.id
|
||||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
if not delete_originals:
|
||||||
if user is not None:
|
overrides.skip_asn_if_exists = True
|
||||||
overrides.owner_id = user.id
|
logger.info(
|
||||||
if not delete_originals:
|
f"Adding split document with pages {split_doc} to the task queue.",
|
||||||
overrides.skip_asn_if_exists = True
|
)
|
||||||
logger.info(
|
consume_tasks.append(
|
||||||
f"Adding split document with pages {split_doc} to the task queue.",
|
consume_file.s(
|
||||||
)
|
input_doc=ConsumableDocument(
|
||||||
consume_tasks.append(
|
source=DocumentSource.ConsumeFolder,
|
||||||
consume_file.s(
|
original_file=filepath,
|
||||||
input_doc=ConsumableDocument(
|
),
|
||||||
source=DocumentSource.ConsumeFolder,
|
overrides=overrides,
|
||||||
original_file=filepath,
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
),
|
)
|
||||||
overrides=overrides,
|
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
backup = release_archive_serial_numbers([doc.id])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
chord(
|
chord(
|
||||||
header=consume_tasks,
|
header=consume_tasks,
|
||||||
body=delete.si([doc.id]),
|
body=delete.si([doc.id]),
|
||||||
).on_error(
|
).on_error(
|
||||||
restore_archive_serial_numbers_task.s(backup),
|
restore_archive_serial_numbers_task.s(backup),
|
||||||
).apply_async()
|
).apply_async()
|
||||||
except Exception:
|
except Exception:
|
||||||
restore_archive_serial_numbers(backup)
|
restore_archive_serial_numbers(backup)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error splitting document {doc.id}: {e}")
|
logger.exception(f"Error splitting document {doc.id}: {e}")
|
||||||
@@ -849,6 +855,8 @@ def delete_pages(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
pages = sorted(pages) # sort pages to avoid index issues
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Produce edited PDF to a temp file and create a new version
|
# Produce edited PDF to a temp file and create a new version
|
||||||
@@ -856,7 +864,13 @@ def delete_pages(
|
|||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
||||||
)
|
)
|
||||||
pdf_ops.remove_pages(pair.source_doc.source_path, filepath, pages)
|
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||||
|
offset = 1 # pages are 1-indexed
|
||||||
|
for page_num in pages:
|
||||||
|
pdf.pages.remove(pdf.pages[page_num - offset])
|
||||||
|
offset += 1 # remove() changes the index of the pages
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
@@ -905,28 +919,47 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
pdf_docs: list[pikepdf.Pdf] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output_count = pdf_ops.validate_page_operations(
|
if not operations:
|
||||||
operations,
|
raise ValueError("Output document index is out of bounds")
|
||||||
single_output=update_document,
|
|
||||||
)
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
page_specs: list[list[pdf_ops.PageSpec]] = [[] for _ in range(output_count)]
|
if update_document and max_idx > 0:
|
||||||
for op in operations:
|
logger.error(
|
||||||
page_specs[op.get("doc", 0)].append(
|
"Update requested but multiple output documents specified",
|
||||||
pdf_ops.PageSpec(op["page"], op.get("rotate", 0)),
|
|
||||||
)
|
)
|
||||||
|
raise ValueError("Multiple output documents specified")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||||
|
for op in operations
|
||||||
|
):
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
|
with pikepdf.open(pair.source_doc.source_path) as src:
|
||||||
|
# prepare output documents
|
||||||
|
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||||
|
|
||||||
|
for op in operations:
|
||||||
|
dst = pdf_docs[op.get("doc", 0)]
|
||||||
|
page = src.pages[op["page"] - 1]
|
||||||
|
dst.pages.append(page)
|
||||||
|
if op.get("rotate"):
|
||||||
|
dst.pages[-1].rotate(op["rotate"], relative=True)
|
||||||
|
|
||||||
if update_document:
|
if update_document:
|
||||||
# Create a new version from the edited PDF rather than replacing in-place
|
# Create a new version from the edited PDF rather than replacing in-place
|
||||||
(filepath,) = pdf_ops.build_pdfs(
|
pdf = pdf_docs[0]
|
||||||
pair.source_doc.source_path,
|
pdf.remove_unreferenced_resources()
|
||||||
[
|
filepath: Path = (
|
||||||
(
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
page_specs[0],
|
/ f"{pair.root_doc.id}_edited.pdf"
|
||||||
partial(_scratch_path, f"{pair.root_doc.id}_edited.pdf"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
pdf.save(filepath)
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if include_metadata
|
if include_metadata
|
||||||
@@ -947,19 +980,6 @@ def edit_pdf(
|
|||||||
headers={"trigger_source": trigger_source},
|
headers={"trigger_source": trigger_source},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
version_filepaths = pdf_ops.build_pdfs(
|
|
||||||
pair.source_doc.source_path,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
specs,
|
|
||||||
partial(
|
|
||||||
_scratch_path,
|
|
||||||
f"{pair.root_doc.id}_edit_{idx}.pdf",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for idx, specs in enumerate(page_specs, start=1)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
consume_tasks = []
|
consume_tasks = []
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
@@ -971,9 +991,15 @@ def edit_pdf(
|
|||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
if not delete_original:
|
if not delete_original:
|
||||||
overrides.skip_asn_if_exists = True
|
overrides.skip_asn_if_exists = True
|
||||||
if delete_original and output_count == 1:
|
if delete_original and len(pdf_docs) == 1:
|
||||||
overrides.asn = pair.root_doc.archive_serial_number
|
overrides.asn = pair.root_doc.archive_serial_number
|
||||||
for version_filepath in version_filepaths:
|
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||||
|
version_filepath: Path = (
|
||||||
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
|
/ f"{pair.root_doc.id}_edit_{idx}.pdf"
|
||||||
|
)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(version_filepath)
|
||||||
consume_tasks.append(
|
consume_tasks.append(
|
||||||
consume_file.s(
|
consume_file.s(
|
||||||
input_doc=ConsumableDocument(
|
input_doc=ConsumableDocument(
|
||||||
@@ -1023,6 +1049,8 @@ def remove_password(
|
|||||||
"""
|
"""
|
||||||
Remove password protection from PDF documents.
|
Remove password protection from PDF documents.
|
||||||
"""
|
"""
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
for doc_id in doc_ids:
|
for doc_id in doc_ids:
|
||||||
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
||||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||||
@@ -1036,69 +1064,76 @@ def remove_password(
|
|||||||
doc.id,
|
doc.id,
|
||||||
pair.source_doc.source_path,
|
pair.source_doc.source_path,
|
||||||
)
|
)
|
||||||
if not pdf_ops.needs_decrypt(source_path):
|
try:
|
||||||
logger.info(
|
with pikepdf.open(source_path) as pdf:
|
||||||
"Skipping password removal for document %s because the "
|
if not pdf.is_encrypted:
|
||||||
"source PDF is not encrypted",
|
logger.info(
|
||||||
pair.root_doc.id,
|
"Skipping password removal for document %s because the "
|
||||||
)
|
"source PDF is not encrypted",
|
||||||
continue
|
pair.root_doc.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except pikepdf.PasswordError:
|
||||||
|
# Password-protected PDFs need the supplied password below.
|
||||||
|
pass
|
||||||
|
|
||||||
filepath = pdf_ops.decrypt_pdf(
|
with pikepdf.open(source_path, password=password) as pdf:
|
||||||
source_path,
|
filepath: Path = (
|
||||||
partial(_scratch_path, f"{pair.root_doc.id}_unprotected.pdf"),
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
password,
|
/ f"{pair.root_doc.id}_unprotected.pdf"
|
||||||
)
|
)
|
||||||
|
pdf.remove_unreferenced_resources()
|
||||||
|
pdf.save(filepath)
|
||||||
|
|
||||||
if update_document:
|
if update_document:
|
||||||
# Create a new version rather than modifying the root/original in place.
|
# Create a new version rather than modifying the root/original in place.
|
||||||
overrides = (
|
overrides = (
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
if include_metadata
|
if include_metadata
|
||||||
else DocumentMetadataOverrides()
|
else DocumentMetadataOverrides()
|
||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
overrides.actor_id = user.id
|
overrides.actor_id = user.id
|
||||||
consume_file.apply_async(
|
consume_file.apply_async(
|
||||||
kwargs={
|
kwargs={
|
||||||
"input_doc": ConsumableDocument(
|
"input_doc": ConsumableDocument(
|
||||||
source=DocumentSource.ConsumeFolder,
|
source=DocumentSource.ConsumeFolder,
|
||||||
original_file=filepath,
|
original_file=filepath,
|
||||||
root_document_id=pair.root_doc.id,
|
root_document_id=pair.root_doc.id,
|
||||||
),
|
),
|
||||||
"overrides": overrides,
|
"overrides": overrides,
|
||||||
},
|
},
|
||||||
headers={"trigger_source": trigger_source},
|
headers={"trigger_source": trigger_source},
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
consume_tasks = []
|
|
||||||
overrides = (
|
|
||||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
|
||||||
if include_metadata
|
|
||||||
else DocumentMetadataOverrides()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
overrides.owner_id = user.id
|
|
||||||
overrides.actor_id = user.id
|
|
||||||
|
|
||||||
consume_tasks.append(
|
|
||||||
consume_file.s(
|
|
||||||
input_doc=ConsumableDocument(
|
|
||||||
source=DocumentSource.ConsumeFolder,
|
|
||||||
original_file=filepath,
|
|
||||||
),
|
|
||||||
overrides=overrides,
|
|
||||||
).set(headers={"trigger_source": trigger_source}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_original:
|
|
||||||
chord(
|
|
||||||
header=consume_tasks,
|
|
||||||
body=delete.si([doc.id]),
|
|
||||||
).delay()
|
|
||||||
else:
|
else:
|
||||||
group(consume_tasks).delay()
|
consume_tasks = []
|
||||||
|
overrides = (
|
||||||
|
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||||
|
if include_metadata
|
||||||
|
else DocumentMetadataOverrides()
|
||||||
|
)
|
||||||
|
if user is not None:
|
||||||
|
overrides.owner_id = user.id
|
||||||
|
overrides.actor_id = user.id
|
||||||
|
|
||||||
|
consume_tasks.append(
|
||||||
|
consume_file.s(
|
||||||
|
input_doc=ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=filepath,
|
||||||
|
),
|
||||||
|
overrides=overrides,
|
||||||
|
).set(headers={"trigger_source": trigger_source}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_original:
|
||||||
|
chord(
|
||||||
|
header=consume_tasks,
|
||||||
|
body=delete.si([doc.id]),
|
||||||
|
).delay()
|
||||||
|
else:
|
||||||
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
"""
|
|
||||||
Pure PDF page operations used by documents.bulk_edit.
|
|
||||||
|
|
||||||
This module deliberately knows nothing about Django, Celery or the documents
|
|
||||||
app: callers resolve documents, choose output paths and queue work. Every
|
|
||||||
function that writes a PDF removes unreferenced resources before saving.
|
|
||||||
|
|
||||||
pikepdf is always called as ``pikepdf.open(...)`` / ``pikepdf.new()`` (never
|
|
||||||
``from pikepdf import open``) so tests can patch those module attributes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
from types import TracebackType
|
|
||||||
|
|
||||||
|
|
||||||
class PageSpec(NamedTuple):
|
|
||||||
"""One page of an output PDF: a 1-indexed source page, optionally rotated."""
|
|
||||||
|
|
||||||
page: int
|
|
||||||
rotate: int = 0 # relative degrees, 0 leaves the page alone
|
|
||||||
|
|
||||||
|
|
||||||
def _require_positive(pages: Iterable[int]) -> None:
|
|
||||||
for page in pages:
|
|
||||||
if page < 1:
|
|
||||||
raise ValueError(f"Page numbers start at 1, got {page}")
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
|
|
||||||
"""
|
|
||||||
Rotate every page relatively on the opened document, not a rebuild, so Info,
|
|
||||||
XMP and outlines are kept. ``src`` is not modified.
|
|
||||||
"""
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
for page in pdf.pages:
|
|
||||||
page.rotate(degrees, relative=True)
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(dst)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
|
|
||||||
"""
|
|
||||||
Remove 1-indexed pages from the opened document, not a rebuild, so Info, XMP
|
|
||||||
and outlines are kept. ``src`` is not modified.
|
|
||||||
|
|
||||||
Duplicates are ignored. Pages are removed highest first so earlier removals
|
|
||||||
never shift the index of later ones.
|
|
||||||
"""
|
|
||||||
unique = sorted(set(pages))
|
|
||||||
_require_positive(unique)
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
for page_num in reversed(unique):
|
|
||||||
del pdf.pages[page_num - 1]
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
pdf.save(dst)
|
|
||||||
|
|
||||||
|
|
||||||
def build_pdfs(
|
|
||||||
src: Path,
|
|
||||||
outputs: Sequence[tuple[Sequence[PageSpec], Callable[[], Path]]],
|
|
||||||
) -> list[Path]:
|
|
||||||
"""
|
|
||||||
Build one new PDF per output from pages of ``src``, opening ``src`` once.
|
|
||||||
|
|
||||||
Each output is ``(page_specs, make_dst)``. Every page number is checked against
|
|
||||||
``src`` before any output is built, and ``make_dst`` is called after that
|
|
||||||
output's pages are copied and immediately before it is saved, so a bad page
|
|
||||||
number in any output never leaves a destination behind. Document-level data
|
|
||||||
(Info, XMP, outlines) is not carried over. Returns the written paths in output
|
|
||||||
order.
|
|
||||||
"""
|
|
||||||
for specs, _ in outputs:
|
|
||||||
_require_positive(spec.page for spec in specs)
|
|
||||||
|
|
||||||
written: list[Path] = []
|
|
||||||
with pikepdf.open(src) as source:
|
|
||||||
page_count = len(source.pages)
|
|
||||||
for specs, _ in outputs:
|
|
||||||
for spec in specs:
|
|
||||||
if spec.page > page_count:
|
|
||||||
raise IndexError(
|
|
||||||
f"Page {spec.page} is out of range, the PDF has "
|
|
||||||
f"{page_count} pages",
|
|
||||||
)
|
|
||||||
for specs, make_dst in outputs:
|
|
||||||
dst = pikepdf.new()
|
|
||||||
for spec in specs:
|
|
||||||
dst.pages.append(source.pages[spec.page - 1])
|
|
||||||
if spec.rotate:
|
|
||||||
dst.pages[-1].rotate(spec.rotate, relative=True)
|
|
||||||
dst.remove_unreferenced_resources()
|
|
||||||
path = make_dst()
|
|
||||||
dst.save(path)
|
|
||||||
dst.close()
|
|
||||||
written.append(path)
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def validate_page_operations(
|
|
||||||
operations: Sequence[Mapping[str, int]],
|
|
||||||
*,
|
|
||||||
single_output: bool,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Validate ``edit_pdf`` style operations and return the output document count.
|
|
||||||
|
|
||||||
Each operation has ``page`` and optionally ``rotate`` and ``doc`` (the output
|
|
||||||
document index, default 0). The bounds rule is kept as it was: a ``doc`` index
|
|
||||||
must be below the number of operations.
|
|
||||||
"""
|
|
||||||
if not operations:
|
|
||||||
raise ValueError("Output document index is out of bounds")
|
|
||||||
|
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
|
||||||
if single_output and max_idx > 0:
|
|
||||||
raise ValueError("Multiple output documents specified")
|
|
||||||
|
|
||||||
if any(
|
|
||||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations) for op in operations
|
|
||||||
):
|
|
||||||
raise ValueError("Output document index is out of bounds")
|
|
||||||
|
|
||||||
return max_idx + 1
|
|
||||||
|
|
||||||
|
|
||||||
def needs_decrypt(src: Path) -> bool:
|
|
||||||
"""
|
|
||||||
True if ``src`` is encrypted. A PDF that needs a password to open at all
|
|
||||||
counts as encrypted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with pikepdf.open(src) as pdf:
|
|
||||||
return bool(pdf.is_encrypted)
|
|
||||||
except pikepdf.PasswordError:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
|
|
||||||
"""
|
|
||||||
Write an unencrypted copy of ``src`` and return its path.
|
|
||||||
|
|
||||||
``make_dst`` is only called once the password has been accepted, so a wrong
|
|
||||||
password never leaves a destination behind.
|
|
||||||
"""
|
|
||||||
with pikepdf.open(src, password=password) as pdf:
|
|
||||||
pdf.remove_unreferenced_resources()
|
|
||||||
dst = make_dst()
|
|
||||||
pdf.save(dst)
|
|
||||||
return dst
|
|
||||||
|
|
||||||
|
|
||||||
class PdfMerger:
|
|
||||||
"""
|
|
||||||
Accumulates the pages of several PDFs into one new PDF.
|
|
||||||
|
|
||||||
``add`` raises if a source cannot be read; deciding whether to skip it is the
|
|
||||||
caller's policy. Use as a context manager so the merged PDF is closed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._pdf = pikepdf.new()
|
|
||||||
self._version: str = self._pdf.pdf_version
|
|
||||||
|
|
||||||
def __enter__(self) -> PdfMerger:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
tb: TracebackType | None,
|
|
||||||
) -> None:
|
|
||||||
self._pdf.close()
|
|
||||||
|
|
||||||
def add(self, path: Path) -> None:
|
|
||||||
with pikepdf.open(str(path)) as pdf:
|
|
||||||
self._version = max(self._version, pdf.pdf_version)
|
|
||||||
self._pdf.pages.extend(pdf.pages)
|
|
||||||
|
|
||||||
def save(self, dst: Path) -> None:
|
|
||||||
self._pdf.remove_unreferenced_resources()
|
|
||||||
self._pdf.save(dst, min_version=self._version)
|
|
||||||
@@ -2145,8 +2145,6 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("pages must be a list")
|
raise serializers.ValidationError("pages must be a list")
|
||||||
if not all(isinstance(i, int) for i in parameters["pages"]):
|
if not all(isinstance(i, int) for i in parameters["pages"]):
|
||||||
raise serializers.ValidationError("pages must be a list of integers")
|
raise serializers.ValidationError("pages must be a list of integers")
|
||||||
if any(i < 1 for i in parameters["pages"]):
|
|
||||||
raise serializers.ValidationError("pages must be positive integers")
|
|
||||||
|
|
||||||
def _validate_parameters_merge(self, parameters) -> None:
|
def _validate_parameters_merge(self, parameters) -> None:
|
||||||
if "delete_originals" in parameters:
|
if "delete_originals" in parameters:
|
||||||
|
|||||||
@@ -1843,36 +1843,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
self.assertEqual(m.call_args.kwargs["pages"], [[1], [2, 3, 4], [5]])
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
|
||||||
def test_bulk_edit_delete_pages_rejects_pages_below_one(self, m) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A legacy delete_pages bulk edit
|
|
||||||
WHEN:
|
|
||||||
- API to bulk edit is called with a page number below 1
|
|
||||||
THEN:
|
|
||||||
- API returns HTTP 400
|
|
||||||
- delete_pages is not called
|
|
||||||
"""
|
|
||||||
self.setup_mock(m, "delete_pages")
|
|
||||||
|
|
||||||
for pages in ([0], [-1], [1, 0]):
|
|
||||||
with self.subTest(pages=pages):
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc2.id],
|
|
||||||
"method": "delete_pages",
|
|
||||||
"parameters": {"pages": pages},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(b"pages must be positive integers", response.content)
|
|
||||||
m.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
def test_rotate_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import shutil
|
import shutil
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -23,7 +23,6 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.pdf_ops import PageSpec
|
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
@@ -797,14 +796,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.img_doc.save()
|
self.img_doc.save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fake_decrypt(
|
def mock_password_required_pdf(
|
||||||
src: Path,
|
mock_open: mock.Mock,
|
||||||
make_dst: Callable[[], Path],
|
fake_pdf: mock.Mock,
|
||||||
password: str,
|
) -> None:
|
||||||
) -> Path:
|
password_context = mock.MagicMock()
|
||||||
dst = make_dst()
|
password_context.__enter__.return_value = fake_pdf
|
||||||
dst.write_bytes(b"password removed")
|
mock_open.side_effect = [
|
||||||
return dst
|
pikepdf.PasswordError("password required"),
|
||||||
|
password_context,
|
||||||
|
]
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge(self, mock_consume_file) -> None:
|
def test_merge(self, mock_consume_file) -> None:
|
||||||
@@ -849,12 +850,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.pdf_ops.PdfMerger")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_merge_uses_latest_version_source_for_root_selection(
|
def test_merge_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_merger,
|
mock_open_pdf,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_merge.pdf"
|
||||||
shutil.copy(self.doc2.source_path, version_file)
|
shutil.copy(self.doc2.source_path, version_file)
|
||||||
@@ -865,14 +866,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
merger = mock_merger.return_value.__enter__.return_value
|
fake_pdf = mock.MagicMock()
|
||||||
merger.save.side_effect = lambda dst: shutil.copy(version.source_path, dst)
|
fake_pdf.pdf_version = "1.7"
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.merge([self.doc2.id])
|
result = bulk_edit.merge([self.doc2.id])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
merger.add.assert_called_once_with(version.source_path)
|
mock_open_pdf.assert_called_once_with(str(version.source_path))
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1034,18 +1037,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.delay")
|
@mock.patch("documents.tasks.consume_file.delay")
|
||||||
@mock.patch("documents.pdf_ops.PdfMerger.add")
|
@mock.patch("pikepdf.open")
|
||||||
def test_merge_with_errors(self, mock_add, mock_consume_file) -> None:
|
def test_merge_with_errors(self, mock_open_pdf, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Merge action is called with 2 documents
|
- Merge action is called with 2 documents
|
||||||
- Error occurs when adding both files
|
- Error occurs when opening both files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_add.side_effect = Exception("Error opening PDF")
|
mock_open_pdf.side_effect = Exception("Error opening PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1082,12 +1085,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_split_uses_latest_version_source_for_root_selection(
|
def test_split_uses_latest_version_source_for_root_selection(
|
||||||
self,
|
self,
|
||||||
mock_consume_file,
|
mock_consume_file,
|
||||||
mock_build_pdfs,
|
mock_open_pdf,
|
||||||
mock_group,
|
mock_group,
|
||||||
) -> None:
|
) -> None:
|
||||||
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
version_file = self.dirs.scratch_dir / "sample2_version_split.pdf"
|
||||||
@@ -1099,15 +1102,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
filename=version_file,
|
filename=version_file,
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
mock_build_pdfs.return_value = [version.source_path, version.source_path]
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
mock_open_pdf.return_value.__enter__.return_value = fake_pdf
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
result = bulk_edit.split([self.doc2.id], [[1], [2]])
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_build_pdfs.call_args.args[0], version.source_path)
|
mock_open_pdf.assert_called_once_with(version.source_path)
|
||||||
self.assertEqual(mock_consume_file.call_count, 2)
|
mock_consume_file.assert_not_called()
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.delete.si")
|
@mock.patch("documents.bulk_edit.delete.si")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@@ -1195,18 +1200,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.doc2.archive_serial_number, 222)
|
self.assertEqual(self.doc2.archive_serial_number, 222)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_split_with_errors(self, mock_build_pdfs, mock_consume_file) -> None:
|
def test_split_with_errors(self, mock_save_pdf, mock_consume_file) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Split action is called with 1 document and 2 page groups
|
- Split action is called with 1 document and 2 page groups
|
||||||
- Error occurs when building the files
|
- Error occurs when saving the files
|
||||||
THEN:
|
THEN:
|
||||||
- Consume file should not be called
|
- Consume file should not be called
|
||||||
"""
|
"""
|
||||||
mock_build_pdfs.side_effect = Exception("Error building PDFs")
|
mock_save_pdf.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [[1, 2], [3]]
|
pages = [[1, 2], [3]]
|
||||||
|
|
||||||
@@ -1241,10 +1246,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.rotate_pdf")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_rotate_with_error(
|
def test_rotate_with_error(
|
||||||
self,
|
self,
|
||||||
mock_rotate_pdf,
|
mock_pdf_save,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1252,11 +1257,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Rotate action is called with 2 documents
|
- Rotate action is called with 2 documents
|
||||||
- Rotating the PDF raises an error
|
- PikePDF raises an error
|
||||||
THEN:
|
THEN:
|
||||||
- Rotate action should be called 0 times
|
- Rotate action should be called 0 times
|
||||||
"""
|
"""
|
||||||
mock_rotate_pdf.side_effect = Exception("Error rotating PDF")
|
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc2.id, self.doc3.id]
|
||||||
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
@@ -1291,10 +1296,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.rotate_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
def test_rotate_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_rotate_pdf,
|
mock_open,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1303,6 +1308,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.rotate(
|
result = bulk_edit.rotate(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1311,35 +1319,26 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_rotate_pdf.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
def test_delete_pages(
|
def test_delete_pages(self, mock_magic, mock_pdf_save, mock_consume_delay) -> None:
|
||||||
self,
|
|
||||||
mock_magic,
|
|
||||||
mock_remove_pages,
|
|
||||||
mock_consume_delay,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
THEN:
|
THEN:
|
||||||
- The pages are removed from the document's source PDF
|
- Save should be called once
|
||||||
- A new version should be enqueued via consume_file
|
- A new version should be enqueued via consume_file
|
||||||
"""
|
"""
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
result = bulk_edit.delete_pages(doc_ids, pages)
|
result = bulk_edit.delete_pages(doc_ids, pages)
|
||||||
mock_remove_pages.assert_called_once_with(
|
mock_pdf_save.assert_called_once()
|
||||||
self.doc2.source_path,
|
|
||||||
mock.ANY,
|
|
||||||
[1, 3],
|
|
||||||
)
|
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, self.doc2.id)
|
||||||
@@ -1351,10 +1350,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.open")
|
||||||
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
def test_delete_pages_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_remove_pages,
|
mock_open,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1363,6 +1362,9 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.delete_pages(
|
result = bulk_edit.delete_pages(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1371,26 +1373,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_remove_pages.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.remove_pages")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_delete_pages_with_error(
|
def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay) -> None:
|
||||||
self,
|
|
||||||
mock_remove_pages,
|
|
||||||
mock_consume_delay,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing documents
|
- Existing documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- Delete pages action is called with 1 document and 2 pages
|
- Delete pages action is called with 1 document and 2 pages
|
||||||
- Removing the pages raises an error
|
- PikePDF raises an error
|
||||||
THEN:
|
THEN:
|
||||||
|
- Save should be called once
|
||||||
- No new version should be enqueued
|
- No new version should be enqueued
|
||||||
"""
|
"""
|
||||||
mock_remove_pages.side_effect = Exception("Error removing pages")
|
mock_pdf_save.side_effect = Exception("Error saving PDF")
|
||||||
doc_ids = [self.doc2.id]
|
doc_ids = [self.doc2.id]
|
||||||
pages = [1, 3]
|
pages = [1, 3]
|
||||||
|
|
||||||
@@ -1420,41 +1419,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
|
||||||
def test_edit_pdf_maps_operations_to_outputs(
|
|
||||||
self,
|
|
||||||
mock_consume_file: mock.Mock,
|
|
||||||
mock_build_pdfs: mock.Mock,
|
|
||||||
mock_group: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing document
|
|
||||||
WHEN:
|
|
||||||
- edit_pdf is called with operations interleaved across two outputs,
|
|
||||||
some of them rotated
|
|
||||||
THEN:
|
|
||||||
- Each output is built from its own operations, in operation order,
|
|
||||||
with the requested rotation
|
|
||||||
"""
|
|
||||||
mock_build_pdfs.return_value = [self.doc2.source_path, self.doc2.source_path]
|
|
||||||
mock_group.return_value.delay.return_value = None
|
|
||||||
operations = [
|
|
||||||
{"page": 3, "doc": 1},
|
|
||||||
{"page": 1, "doc": 0, "rotate": 90},
|
|
||||||
{"page": 2, "doc": 1, "rotate": 180},
|
|
||||||
]
|
|
||||||
|
|
||||||
bulk_edit.edit_pdf([self.doc2.id], operations)
|
|
||||||
|
|
||||||
outputs = mock_build_pdfs.call_args.args[1]
|
|
||||||
self.assertEqual(
|
|
||||||
[specs for specs, _ in outputs],
|
|
||||||
[[PageSpec(1, 90)], [PageSpec(3), PageSpec(2, 180)]],
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group) -> None:
|
||||||
@@ -1570,10 +1534,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.build_pdfs")
|
@mock.patch("pikepdf.new")
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
def test_edit_pdf_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_build_pdfs,
|
mock_open,
|
||||||
|
mock_new,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1582,7 +1548,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="B version 1",
|
title="B version 1",
|
||||||
root_document=self.doc2,
|
root_document=self.doc2,
|
||||||
)
|
)
|
||||||
mock_build_pdfs.return_value = [Path("edited.pdf")]
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock()]
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
output_pdf = mock.MagicMock()
|
||||||
|
output_pdf.pages = []
|
||||||
|
mock_new.return_value = output_pdf
|
||||||
|
|
||||||
result = bulk_edit.edit_pdf(
|
result = bulk_edit.edit_pdf(
|
||||||
[self.doc2.id],
|
[self.doc2.id],
|
||||||
@@ -1592,7 +1563,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
self.assertEqual(mock_build_pdfs.call_args.args[0], self.doc2.source_path)
|
mock_open.assert_called_once_with(self.doc2.source_path)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@@ -1618,6 +1589,31 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_group.return_value.delay.assert_called_once()
|
mock_group.return_value.delay.assert_called_once()
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.group")
|
||||||
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
|
def test_edit_pdf_open_failure(
|
||||||
|
self,
|
||||||
|
mock_consume_file: mock.Mock,
|
||||||
|
mock_group: mock.Mock,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document
|
||||||
|
WHEN:
|
||||||
|
- edit_pdf fails to open PDF
|
||||||
|
THEN:
|
||||||
|
- Task group is not called
|
||||||
|
"""
|
||||||
|
doc_ids = [self.doc2.id]
|
||||||
|
operations = [
|
||||||
|
{"page": 9999}, # invalid page, forces error during PDF load
|
||||||
|
]
|
||||||
|
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
bulk_edit.edit_pdf(doc_ids, operations)
|
||||||
|
mock_group.assert_not_called()
|
||||||
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
||||||
@@ -1644,15 +1640,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
|
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
|
||||||
|
for operations in ([], [{"page": 1, "doc": 2**32}]):
|
||||||
|
with self.subTest(operations=operations):
|
||||||
|
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
|
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
||||||
|
bulk_edit.edit_pdf([self.doc2.id], operations)
|
||||||
|
|
||||||
|
mock_open.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_update_document(
|
def test_remove_password_update_document(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1661,7 +1665,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
temp_dir = self.dirs.scratch_dir / "remove-password-update"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()]
|
||||||
|
fake_pdf.is_encrypted = True
|
||||||
|
|
||||||
|
def save_side_effect(target_path):
|
||||||
|
Path(target_path).write_bytes(b"new pdf content")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1670,8 +1683,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fake_pdf.remove_unreferenced_resources.assert_called_once()
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
task_kwargs = mock_consume_delay.call_args.kwargs["kwargs"]
|
||||||
@@ -1684,15 +1703,40 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
self.assertEqual(task_kwargs["input_doc"].root_document_id, doc.id)
|
||||||
self.assertIsNotNone(task_kwargs["overrides"])
|
self.assertIsNotNone(task_kwargs["overrides"])
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
|
def test_remove_password_update_document_skips_unencrypted_pdf(
|
||||||
|
self,
|
||||||
|
mock_open,
|
||||||
|
mock_mkdtemp,
|
||||||
|
mock_consume_delay,
|
||||||
|
) -> None:
|
||||||
|
doc = self.doc1
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.is_encrypted = False
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
|
result = bulk_edit.remove_password(
|
||||||
|
[doc.id],
|
||||||
|
password="secret",
|
||||||
|
update_document=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, "OK")
|
||||||
|
mock_open.assert_called_once_with(doc.source_path)
|
||||||
|
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
||||||
|
fake_pdf.save.assert_not_called()
|
||||||
|
mock_mkdtemp.assert_not_called()
|
||||||
|
mock_consume_delay.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_update_document_uses_source_paths(
|
def test_remove_password_update_document_uses_source_paths(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_mkdtemp,
|
mock_mkdtemp,
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_update_document,
|
mock_update_document,
|
||||||
@@ -1703,7 +1747,14 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
temp_dir = self.dirs.scratch_dir / "remove-password-source-file"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path):
|
||||||
|
Path(target_path).write_bytes(b"new pdf content")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1713,19 +1764,22 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(source_file)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(source_file, mock.ANY, "secret")
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(source_file),
|
||||||
|
mock.call(source_file, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_update_document.assert_not_called()
|
mock_update_document.assert_not_called()
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
@mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
def test_remove_password_explicit_selection_uses_root_source_when_root_selected(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt,
|
mock_open,
|
||||||
mock_decrypt,
|
|
||||||
mock_consume_delay,
|
mock_consume_delay,
|
||||||
mock_magic,
|
mock_magic,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1734,7 +1788,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
title="A version 1",
|
title="A version 1",
|
||||||
root_document=self.doc1,
|
root_document=self.doc1,
|
||||||
)
|
)
|
||||||
mock_decrypt.return_value = Path("unprotected.pdf")
|
fake_pdf = mock.MagicMock()
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[self.doc1.id],
|
[self.doc1.id],
|
||||||
@@ -1744,11 +1799,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(self.doc1.source_path)
|
self.assertEqual(
|
||||||
mock_decrypt.assert_called_once_with(
|
mock_open.call_args_list,
|
||||||
self.doc1.source_path,
|
[
|
||||||
mock.ANY,
|
mock.call(self.doc1.source_path),
|
||||||
"secret",
|
mock.call(self.doc1.source_path, password="secret"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
mock_consume_delay.assert_called_once()
|
mock_consume_delay.assert_called_once()
|
||||||
|
|
||||||
@@ -1756,12 +1812,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_creates_consumable_document(
|
def test_remove_password_creates_consumable_document(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1771,7 +1825,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password"
|
temp_dir = self.dirs.scratch_dir / "remove-password"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path: Path) -> None:
|
||||||
|
target_path.write_bytes(b"password removed")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
mock_group.return_value.delay.return_value = None
|
mock_group.return_value.delay.return_value = None
|
||||||
|
|
||||||
user = User.objects.create(username="owner")
|
user = User.objects.create(username="owner")
|
||||||
@@ -1786,7 +1848,13 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
self.assertEqual(
|
||||||
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
call_kwargs = mock_consume_file.call_args.kwargs
|
call_kwargs = mock_consume_file.call_args.kwargs
|
||||||
consumable_document = call_kwargs["input_doc"]
|
consumable_document = call_kwargs["input_doc"]
|
||||||
@@ -1808,18 +1876,21 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.chord")
|
@mock.patch("documents.bulk_edit.chord")
|
||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=False)
|
@mock.patch("pikepdf.open")
|
||||||
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
def test_remove_password_skips_unencrypted_pdf_without_queueing(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
mock_chord: mock.Mock,
|
mock_chord: mock.Mock,
|
||||||
mock_delete: mock.Mock,
|
mock_delete: mock.Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
doc = self.doc2
|
doc = self.doc2
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.is_encrypted = False
|
||||||
|
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
[doc.id],
|
[doc.id],
|
||||||
@@ -1829,8 +1900,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_needs_decrypt.assert_called_once_with(doc.source_path)
|
mock_open.assert_called_once_with(doc.source_path)
|
||||||
mock_decrypt.assert_not_called()
|
fake_pdf.remove_unreferenced_resources.assert_not_called()
|
||||||
|
fake_pdf.save.assert_not_called()
|
||||||
|
mock_mkdtemp.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_not_called()
|
mock_chord.assert_not_called()
|
||||||
@@ -1841,12 +1914,10 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
@mock.patch("documents.bulk_edit.group")
|
@mock.patch("documents.bulk_edit.group")
|
||||||
@mock.patch("documents.tasks.consume_file.s")
|
@mock.patch("documents.tasks.consume_file.s")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
@mock.patch("documents.pdf_ops.decrypt_pdf")
|
@mock.patch("pikepdf.open")
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_deletes_original(
|
def test_remove_password_deletes_original(
|
||||||
self,
|
self,
|
||||||
mock_needs_decrypt: mock.Mock,
|
mock_open: mock.Mock,
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
mock_mkdtemp: mock.Mock,
|
mock_mkdtemp: mock.Mock,
|
||||||
mock_consume_file: mock.Mock,
|
mock_consume_file: mock.Mock,
|
||||||
mock_group: mock.Mock,
|
mock_group: mock.Mock,
|
||||||
@@ -1857,7 +1928,15 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
temp_dir = self.dirs.scratch_dir / "remove-password-delete"
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
mock_mkdtemp.return_value = str(temp_dir)
|
mock_mkdtemp.return_value = str(temp_dir)
|
||||||
mock_decrypt.side_effect = self.fake_decrypt
|
|
||||||
|
fake_pdf = mock.MagicMock()
|
||||||
|
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||||
|
self.mock_password_required_pdf(mock_open, fake_pdf)
|
||||||
|
|
||||||
|
def save_side_effect(target_path: Path) -> None:
|
||||||
|
target_path.write_bytes(b"password removed")
|
||||||
|
|
||||||
|
fake_pdf.save.side_effect = save_side_effect
|
||||||
mock_chord.return_value.delay.return_value = None
|
mock_chord.return_value.delay.return_value = None
|
||||||
|
|
||||||
result = bulk_edit.remove_password(
|
result = bulk_edit.remove_password(
|
||||||
@@ -1869,23 +1948,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
mock_decrypt.assert_called_once_with(doc.source_path, mock.ANY, "secret")
|
self.assertEqual(
|
||||||
|
mock_open.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(doc.source_path),
|
||||||
|
mock.call(doc.source_path, password="secret"),
|
||||||
|
],
|
||||||
|
)
|
||||||
mock_consume_file.assert_called_once()
|
mock_consume_file.assert_called_once()
|
||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_chord.assert_called_once()
|
mock_chord.assert_called_once()
|
||||||
mock_chord.return_value.delay.assert_called_once()
|
mock_chord.return_value.delay.assert_called_once()
|
||||||
mock_delete.si.assert_called_once_with([doc.id])
|
mock_delete.si.assert_called_once_with([doc.id])
|
||||||
|
|
||||||
@mock.patch(
|
@mock.patch("pikepdf.open")
|
||||||
"documents.pdf_ops.decrypt_pdf",
|
def test_remove_password_open_failure(self, mock_open: mock.Mock) -> None:
|
||||||
side_effect=RuntimeError("wrong password"),
|
mock_open.side_effect = RuntimeError("wrong password")
|
||||||
)
|
|
||||||
@mock.patch("documents.pdf_ops.needs_decrypt", return_value=True)
|
|
||||||
def test_remove_password_failure_raises_value_error(
|
|
||||||
self,
|
|
||||||
mock_needs_decrypt: mock.Mock,
|
|
||||||
mock_decrypt: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||||
with self.assertRaises(ValueError) as exc:
|
with self.assertRaises(ValueError) as exc:
|
||||||
bulk_edit.remove_password([self.doc1.id], password="secret")
|
bulk_edit.remove_password([self.doc1.id], password="secret")
|
||||||
|
|||||||
@@ -1,520 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for documents.pdf_ops.
|
|
||||||
|
|
||||||
These use real PDFs from the sample directories. No database, Celery or mocks.
|
|
||||||
Pages are compared by a hash of their content stream, so page identity and order
|
|
||||||
are easy to assert.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pikepdf
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from documents import pdf_ops
|
|
||||||
from documents.pdf_ops import PageSpec
|
|
||||||
|
|
||||||
SRC_ROOT = Path(__file__).parents[2]
|
|
||||||
SAMPLES = Path(__file__).parent / "samples"
|
|
||||||
THREE_PAGES = SAMPLES / "documents" / "originals" / "0000002.pdf"
|
|
||||||
TWELVE_PAGES = SAMPLES / "barcodes" / "split-by-asn-2.pdf"
|
|
||||||
ENCRYPTED = SAMPLES / "password-is-test.pdf"
|
|
||||||
SIGNED = SRC_ROOT / "paperless" / "tests" / "samples" / "tesseract" / "signed.pdf"
|
|
||||||
|
|
||||||
|
|
||||||
def _page_fingerprint(page: pikepdf.Page) -> str:
|
|
||||||
contents = page.obj.get("/Contents")
|
|
||||||
assert contents is not None, "sample page has no /Contents"
|
|
||||||
streams = list(contents) if isinstance(contents, pikepdf.Array) else [contents]
|
|
||||||
return hashlib.sha256(b"".join(s.read_bytes() for s in streams)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprints(path: Path) -> list[str]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return [_page_fingerprint(page) for page in pdf.pages]
|
|
||||||
|
|
||||||
|
|
||||||
def rotations(path: Path) -> list[int]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return [int(page.obj.get("/Rotate", 0)) for page in pdf.pages]
|
|
||||||
|
|
||||||
|
|
||||||
def docinfo_keys(path: Path) -> set[str]:
|
|
||||||
with pikepdf.open(path) as pdf:
|
|
||||||
return set(pdf.docinfo.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def constant(path: Path) -> Callable[[], Path]:
|
|
||||||
return lambda: path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def source_fingerprints() -> list[str]:
|
|
||||||
fps = fingerprints(THREE_PAGES)
|
|
||||||
assert len(set(fps)) == 3, "sample must have three distinct pages"
|
|
||||||
return fps
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotatePdf:
|
|
||||||
def test_rotation_is_relative_and_applies_to_every_page(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- It is rotated by 90 degrees, then the result is rotated by 90 again
|
|
||||||
THEN:
|
|
||||||
- Every page is rotated relative to its current rotation
|
|
||||||
- The page content itself is unchanged
|
|
||||||
"""
|
|
||||||
once = tmp_path / "once.pdf"
|
|
||||||
twice = tmp_path / "twice.pdf"
|
|
||||||
|
|
||||||
pdf_ops.rotate_pdf(THREE_PAGES, once, 90)
|
|
||||||
pdf_ops.rotate_pdf(once, twice, 90)
|
|
||||||
|
|
||||||
assert rotations(once) == [90, 90, 90]
|
|
||||||
assert rotations(twice) == [180, 180, 180]
|
|
||||||
assert fingerprints(twice) == fingerprints(THREE_PAGES)
|
|
||||||
|
|
||||||
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF with document info
|
|
||||||
WHEN:
|
|
||||||
- It is rotated
|
|
||||||
THEN:
|
|
||||||
- The document info is still present in the output
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.rotate_pdf(THREE_PAGES, dst, 90)
|
|
||||||
|
|
||||||
assert "/Creator" in docinfo_keys(dst)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemovePages:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("pages", "kept"),
|
|
||||||
[
|
|
||||||
pytest.param([2], [0, 2], id="single"),
|
|
||||||
pytest.param([3, 1], [1], id="unordered"),
|
|
||||||
pytest.param([2, 2], [0, 2], id="duplicates-remove-once"),
|
|
||||||
pytest.param([], [0, 1, 2], id="empty-keeps-everything"),
|
|
||||||
pytest.param([1, 2, 3], [], id="every-page"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_removes_only_the_requested_pages(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
pages: list[int],
|
|
||||||
kept: list[int],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Pages are removed, in any order and possibly repeated
|
|
||||||
THEN:
|
|
||||||
- Exactly the other pages remain, in their original order
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, pages)
|
|
||||||
|
|
||||||
assert fingerprints(dst) == [source_fingerprints[i] for i in kept]
|
|
||||||
|
|
||||||
def test_keeps_document_info(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF with document info
|
|
||||||
WHEN:
|
|
||||||
- A page is removed
|
|
||||||
THEN:
|
|
||||||
- The document info is still present in the output
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, [1])
|
|
||||||
|
|
||||||
assert "/Creator" in docinfo_keys(dst)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"bad_page",
|
|
||||||
[
|
|
||||||
pytest.param(0, id="zero"),
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_rejects_pages_below_one(self, tmp_path: Path, bad_page: int) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Pages are removed and one of them is below 1
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
- No output file is written
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="start at 1"):
|
|
||||||
pdf_ops.remove_pages(THREE_PAGES, dst, [1, bad_page])
|
|
||||||
|
|
||||||
assert not dst.exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPdfs:
|
|
||||||
def test_selects_and_orders_pages(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- One output is built from pages 3 then 1
|
|
||||||
THEN:
|
|
||||||
- The output has those pages in that order
|
|
||||||
- Its path is returned
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
written = pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(3), PageSpec(1)], constant(dst))],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert written == [dst]
|
|
||||||
assert fingerprints(dst) == [source_fingerprints[2], source_fingerprints[0]]
|
|
||||||
|
|
||||||
def test_rotates_only_the_requested_pages(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- One output is built with a different rotation on each page
|
|
||||||
THEN:
|
|
||||||
- Each output page has exactly the rotation requested for it
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1), PageSpec(2, 90), PageSpec(3, 180)], constant(dst))],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rotations(dst) == [0, 90, 180]
|
|
||||||
|
|
||||||
def test_writes_one_file_per_output_in_order(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A twelve page PDF
|
|
||||||
WHEN:
|
|
||||||
- Two outputs are built from different page ranges
|
|
||||||
THEN:
|
|
||||||
- Two files are written and returned in output order
|
|
||||||
- Each holds exactly its own pages
|
|
||||||
"""
|
|
||||||
first = tmp_path / "first.pdf"
|
|
||||||
second = tmp_path / "second.pdf"
|
|
||||||
source = fingerprints(TWELVE_PAGES)
|
|
||||||
|
|
||||||
written = pdf_ops.build_pdfs(
|
|
||||||
TWELVE_PAGES,
|
|
||||||
[
|
|
||||||
([PageSpec(p) for p in (1, 2, 3)], constant(first)),
|
|
||||||
([PageSpec(p) for p in range(4, 13)], constant(second)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert written == [first, second]
|
|
||||||
assert fingerprints(first) == source[:3]
|
|
||||||
assert fingerprints(second) == source[3:]
|
|
||||||
|
|
||||||
def test_empty_page_list_writes_a_zero_page_file(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- An output with no pages is built
|
|
||||||
THEN:
|
|
||||||
- A PDF with zero pages is written
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
pdf_ops.build_pdfs(THREE_PAGES, [([], constant(dst))])
|
|
||||||
|
|
||||||
assert fingerprints(dst) == []
|
|
||||||
|
|
||||||
def test_destination_is_not_requested_when_a_page_is_out_of_range(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Several outputs are built
|
|
||||||
- A later output refers to a page past the end
|
|
||||||
THEN:
|
|
||||||
- IndexError is raised
|
|
||||||
- No destination was requested for any output, including earlier valid ones
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(IndexError):
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1)], make_dst), ([PageSpec(99)], make_dst)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"bad_page",
|
|
||||||
[
|
|
||||||
pytest.param(0, id="zero"),
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_rejects_pages_below_one_before_opening_anything(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
bad_page: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Several outputs are built and a later one has a page below 1
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
- No destination was requested for any output
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="start at 1"):
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(1)], make_dst), ([PageSpec(bad_page)], make_dst)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePageOperations:
|
|
||||||
def test_returns_the_output_count(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that all target the default output
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- One output document is reported
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1}, {"page": 2}, {"page": 3}]
|
|
||||||
|
|
||||||
assert pdf_ops.validate_page_operations(operations, single_output=True) == 1
|
|
||||||
|
|
||||||
def test_gap_in_output_indices_counts_up_to_the_highest(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that target outputs 0 and 2 but never 1
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- Three output documents are reported
|
|
||||||
"""
|
|
||||||
operations = [
|
|
||||||
{"page": 1, "doc": 0},
|
|
||||||
{"page": 2, "doc": 2},
|
|
||||||
{"page": 3, "doc": 0},
|
|
||||||
]
|
|
||||||
|
|
||||||
count = pdf_ops.validate_page_operations(operations, single_output=False)
|
|
||||||
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
def test_empty_operations_are_rejected(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No operations
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
with pytest.raises(ValueError, match="index is out of bounds"):
|
|
||||||
pdf_ops.validate_page_operations([], single_output=False)
|
|
||||||
|
|
||||||
def test_multiple_outputs_rejected_when_single_output_required(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Operations that target two outputs
|
|
||||||
WHEN:
|
|
||||||
- They are validated with a single output required
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Multiple output documents"):
|
|
||||||
pdf_ops.validate_page_operations(operations, single_output=True)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"doc",
|
|
||||||
[
|
|
||||||
pytest.param(-1, id="negative"),
|
|
||||||
pytest.param(2, id="equal-to-operation-count"),
|
|
||||||
pytest.param(2**32, id="huge"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_output_index_out_of_bounds(self, doc: int) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two operations, one with an output index that is out of bounds
|
|
||||||
WHEN:
|
|
||||||
- They are validated
|
|
||||||
THEN:
|
|
||||||
- ValueError is raised
|
|
||||||
"""
|
|
||||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": doc}]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="index is out of bounds"):
|
|
||||||
pdf_ops.validate_page_operations(operations, single_output=False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDecrypt:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("path", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(ENCRYPTED, True, id="password-required"),
|
|
||||||
pytest.param(SIGNED, True, id="opens-without-password-but-encrypted"),
|
|
||||||
pytest.param(THREE_PAGES, False, id="not-encrypted"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_needs_decrypt(self, path: Path, *, expected: bool) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A PDF that is encrypted, or encrypted but openable, or plain
|
|
||||||
WHEN:
|
|
||||||
- needs_decrypt is asked about it
|
|
||||||
THEN:
|
|
||||||
- Only the unencrypted PDF reports False
|
|
||||||
"""
|
|
||||||
assert pdf_ops.needs_decrypt(path) is expected
|
|
||||||
|
|
||||||
def test_decrypt_writes_an_unencrypted_copy(self, tmp_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A password protected PDF
|
|
||||||
WHEN:
|
|
||||||
- It is decrypted with the correct password
|
|
||||||
THEN:
|
|
||||||
- The path from make_dst is returned
|
|
||||||
- The written copy no longer needs decrypting
|
|
||||||
"""
|
|
||||||
dst = tmp_path / "out.pdf"
|
|
||||||
|
|
||||||
result = pdf_ops.decrypt_pdf(ENCRYPTED, constant(dst), "test")
|
|
||||||
|
|
||||||
assert result == dst
|
|
||||||
assert pdf_ops.needs_decrypt(dst) is False
|
|
||||||
|
|
||||||
def test_wrong_password_raises_and_never_requests_a_destination(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A password protected PDF
|
|
||||||
WHEN:
|
|
||||||
- It is decrypted with the wrong password
|
|
||||||
THEN:
|
|
||||||
- PasswordError is raised
|
|
||||||
- No destination was requested
|
|
||||||
"""
|
|
||||||
requested: list[Path] = []
|
|
||||||
|
|
||||||
def make_dst() -> Path:
|
|
||||||
requested.append(tmp_path / "out.pdf")
|
|
||||||
return requested[-1]
|
|
||||||
|
|
||||||
with pytest.raises(pikepdf.PasswordError):
|
|
||||||
pdf_ops.decrypt_pdf(ENCRYPTED, make_dst, "wrong")
|
|
||||||
|
|
||||||
assert requested == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestPdfMerger:
|
|
||||||
def test_pages_are_appended_in_the_order_added(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
source_fingerprints: list[str],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reordered PDF and the original three page PDF
|
|
||||||
WHEN:
|
|
||||||
- Both are added to a merger in that order and saved
|
|
||||||
THEN:
|
|
||||||
- The output holds all pages in the order they were added
|
|
||||||
"""
|
|
||||||
reordered = tmp_path / "reordered.pdf"
|
|
||||||
merged = tmp_path / "merged.pdf"
|
|
||||||
pdf_ops.build_pdfs(
|
|
||||||
THREE_PAGES,
|
|
||||||
[([PageSpec(3), PageSpec(1)], constant(reordered))],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
merger.add(reordered)
|
|
||||||
merger.add(THREE_PAGES)
|
|
||||||
merger.save(merged)
|
|
||||||
|
|
||||||
assert fingerprints(merged) == [
|
|
||||||
source_fingerprints[2],
|
|
||||||
source_fingerprints[0],
|
|
||||||
*source_fingerprints,
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_output_version_is_at_least_the_highest_source_version(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Two PDFs with different PDF versions
|
|
||||||
WHEN:
|
|
||||||
- Both are added to a merger and saved
|
|
||||||
THEN:
|
|
||||||
- The output version is at least the highest source version
|
|
||||||
"""
|
|
||||||
merged = tmp_path / "merged.pdf"
|
|
||||||
with pikepdf.open(TWELVE_PAGES) as pdf:
|
|
||||||
source_versions = [pdf.pdf_version]
|
|
||||||
with pikepdf.open(THREE_PAGES) as pdf:
|
|
||||||
source_versions.append(pdf.pdf_version)
|
|
||||||
|
|
||||||
with pdf_ops.PdfMerger() as merger:
|
|
||||||
merger.add(TWELVE_PAGES)
|
|
||||||
merger.add(THREE_PAGES)
|
|
||||||
merger.save(merged)
|
|
||||||
|
|
||||||
with pikepdf.open(merged) as pdf:
|
|
||||||
assert pdf.pdf_version >= max(source_versions)
|
|
||||||
Reference in new issue
Block a user