mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
8 Commits
v6.0.0
...
cfd869e05a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd869e05a | ||
|
|
b191776f65 | ||
|
|
f41f354737 | ||
|
|
6b57ee099d | ||
|
|
21ecb60cb5 | ||
|
|
ee00019f2e | ||
|
|
d49c739fe4 | ||
|
|
dbdf82a079 |
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and
|
||||
associated documentation files (the "Font Software"), to reproduce
|
||||
and distribute the modifications to the Bitstream Vera Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be included in all copies of one or more of the Font Software
|
||||
typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in
|
||||
particular the designs of glyphs or characters in the Fonts may be
|
||||
modified and additional glyphs or characters may be added to the
|
||||
Fonts, only if the fonts are renamed to names not containing either
|
||||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts
|
||||
or Font Software that has been modified and is distributed under the
|
||||
"Tavmjong Bah Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy of one or more of the Font Software typefaces may be sold by
|
||||
itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||
be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in this Font Software without prior written authorization
|
||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||
. fr.
|
||||
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
Binary file not shown.
5529
main_window_old.py
Normal file
5529
main_window_old.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
# --- Standard Library Imports ---
|
||||
import time
|
||||
import traceback
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# --- Third-Party Library Imports ---
|
||||
import json # Ensure json is imported
|
||||
import requests
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# (Keep the rest of your imports)
|
||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_POST_TITLE
|
||||
@@ -15,36 +13,24 @@ from ..config.constants import (
|
||||
|
||||
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||
"""
|
||||
Fetches a single page of posts from the API with retry logic.
|
||||
|
||||
Args:
|
||||
api_url_base (str): The base URL for the user's posts.
|
||||
headers (dict): The request headers.
|
||||
offset (int): The offset for pagination.
|
||||
logger (callable): Function to log messages.
|
||||
cancellation_event (threading.Event): Event to signal cancellation.
|
||||
pause_event (threading.Event): Event to signal pause.
|
||||
cookies_dict (dict): A dictionary of cookies to include in the request.
|
||||
|
||||
Returns:
|
||||
list: A list of post data dictionaries from the API.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the fetch fails after all retries or encounters a non-retryable error.
|
||||
Fetches a single page of posts from the API with robust retry logic.
|
||||
NEW: Requests only essential fields to keep the response size small and reliable.
|
||||
"""
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Fetch cancelled before request.")
|
||||
raise RuntimeError("Fetch operation cancelled by user.")
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Post fetching paused...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Post fetching cancelled while paused.")
|
||||
raise RuntimeError("Fetch operation cancelled by user.")
|
||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||
time.sleep(0.5)
|
||||
logger(" Post fetching resumed.")
|
||||
|
||||
paginated_url = f'{api_url_base}?o={offset}'
|
||||
|
||||
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
|
||||
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 5
|
||||
|
||||
@@ -52,22 +38,18 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
raise RuntimeError("Fetch operation cancelled by user during retry loop.")
|
||||
|
||||
log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})"
|
||||
log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})"
|
||||
if attempt > 0:
|
||||
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
||||
logger(log_message)
|
||||
|
||||
try:
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict)
|
||||
# We can now remove the streaming logic as the response will be small and fast.
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
|
||||
if 'application/json' not in response.headers.get('Content-Type', '').lower():
|
||||
logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}")
|
||||
return []
|
||||
|
||||
return response.json()
|
||||
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
delay = retry_delay * (2 ** attempt)
|
||||
@@ -76,18 +58,46 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
continue
|
||||
else:
|
||||
logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
|
||||
raise RuntimeError(f"Timeout or connection error fetching offset {offset}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
err_msg = f"Error fetching offset {offset}: {e}"
|
||||
if e.response is not None:
|
||||
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
|
||||
raise RuntimeError(err_msg)
|
||||
except ValueError as e: # JSON decode error
|
||||
raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}")
|
||||
raise RuntimeError(f"Network error fetching offset {offset}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
delay = retry_delay * (2 ** attempt)
|
||||
logger(f" Retrying in {delay} seconds...")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(f"JSONDecodeError fetching offset {offset}")
|
||||
|
||||
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
||||
|
||||
|
||||
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
|
||||
"""
|
||||
--- NEW FUNCTION ---
|
||||
Fetches the full data, including the 'content' field, for a single post.
|
||||
"""
|
||||
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
|
||||
logger(f" Fetching full content for post ID {post_id}...")
|
||||
try:
|
||||
# Use streaming here as a precaution for single posts that are still very large.
|
||||
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
response_body = b""
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
response_body += chunk
|
||||
|
||||
full_post_data = json.loads(response_body)
|
||||
# The API sometimes wraps the post in a list, handle that.
|
||||
if isinstance(full_post_data, list) and full_post_data:
|
||||
return full_post_data[0]
|
||||
return full_post_data
|
||||
|
||||
except Exception as e:
|
||||
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||
"""Fetches all comments for a specific post."""
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
|
||||
1954
src/core/workers.py
1954
src/core/workers.py
File diff suppressed because it is too large
Load Diff
@@ -144,7 +144,7 @@ class EmptyPopupDialog (QDialog ):
|
||||
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
|
||||
|
||||
self .parent_app =parent_app_ref
|
||||
self .current_scope_mode =self .SCOPE_CHARACTERS
|
||||
self.current_scope_mode = self.SCOPE_CREATORS
|
||||
self .app_base_dir =app_base_dir
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
|
||||
@@ -126,6 +126,21 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
self .artist_list_widget .setVisible (show )
|
||||
|
||||
def _fetch_favorite_artists (self ):
|
||||
|
||||
if self.cookies_config['use_cookie']:
|
||||
# Check if we can load cookies for at least one of the services.
|
||||
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
|
||||
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
|
||||
|
||||
if not kemono_cookies and not coomer_cookies:
|
||||
# If cookies are enabled but none could be loaded, show help and stop.
|
||||
self.status_label.setText(self._tr("fav_artists_cookies_required_status", "Error: Cookies enabled but could not be loaded for any source."))
|
||||
self._logger("Error: Cookies enabled but no valid cookies were loaded. Showing help dialog.")
|
||||
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
|
||||
cookie_help_dialog.exec_()
|
||||
self.download_button.setEnabled(False)
|
||||
return # Stop further execution
|
||||
|
||||
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
|
||||
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
|
||||
|
||||
|
||||
83
src/ui/dialogs/MoreOptionsDialog.py
Normal file
83
src/ui/dialogs/MoreOptionsDialog.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
class MoreOptionsDialog(QDialog):
|
||||
"""
|
||||
A dialog for selecting a scope, export format, and single PDF option.
|
||||
"""
|
||||
SCOPE_CONTENT = "content"
|
||||
SCOPE_COMMENTS = "comments"
|
||||
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("More Options")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
# ... (Layout and other widgets remain the same) ...
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.description_label = QLabel("Please choose the scope for the action:")
|
||||
layout.addWidget(self.description_label)
|
||||
self.radio_button_group = QButtonGroup(self)
|
||||
self.radio_content = QRadioButton("Description/Content")
|
||||
self.radio_comments = QRadioButton("Comments")
|
||||
self.radio_button_group.addButton(self.radio_content)
|
||||
self.radio_button_group.addButton(self.radio_comments)
|
||||
layout.addWidget(self.radio_content)
|
||||
layout.addWidget(self.radio_comments)
|
||||
|
||||
if current_scope == self.SCOPE_COMMENTS:
|
||||
self.radio_comments.setChecked(True)
|
||||
else:
|
||||
self.radio_content.setChecked(True)
|
||||
|
||||
export_layout = QHBoxLayout()
|
||||
export_label = QLabel("Export as:")
|
||||
self.format_combo = QComboBox()
|
||||
self.format_combo.addItems(["PDF", "DOCX", "TXT"])
|
||||
|
||||
if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]:
|
||||
self.format_combo.setCurrentText(current_format.upper())
|
||||
else:
|
||||
self.format_combo.setCurrentText("PDF")
|
||||
|
||||
export_layout.addWidget(export_label)
|
||||
export_layout.addWidget(self.format_combo)
|
||||
export_layout.addStretch()
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
# --- UPDATED: Single PDF Checkbox ---
|
||||
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
||||
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
||||
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
||||
layout.addWidget(self.single_pdf_checkbox)
|
||||
|
||||
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
||||
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
|
||||
def update_single_pdf_checkbox_state(self, text):
|
||||
"""Enable the Single PDF checkbox only if the format is PDF."""
|
||||
is_pdf = (text.upper() == "PDF")
|
||||
self.single_pdf_checkbox.setEnabled(is_pdf)
|
||||
if not is_pdf:
|
||||
self.single_pdf_checkbox.setChecked(False)
|
||||
|
||||
def get_selected_scope(self):
|
||||
if self.radio_comments.isChecked():
|
||||
return self.SCOPE_COMMENTS
|
||||
return self.SCOPE_CONTENT
|
||||
|
||||
def get_selected_format(self):
|
||||
return self.format_combo.currentText().lower()
|
||||
|
||||
def get_single_pdf_state(self):
|
||||
"""Returns the state of the Single PDF checkbox."""
|
||||
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
|
||||
77
src/ui/dialogs/SinglePDF.py
Normal file
77
src/ui/dialogs/SinglePDF.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# SinglePDF.py
|
||||
|
||||
import os
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
# No header
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
# Position at 1.5 cm from bottom
|
||||
self.set_y(-15)
|
||||
self.set_font('DejaVu', '', 8)
|
||||
# Page number
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
"""
|
||||
Creates a single PDF from a list of post titles and content.
|
||||
|
||||
Args:
|
||||
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
|
||||
output_filename (str): The full path for the output PDF file.
|
||||
font_path (str): Path to the DejaVuSans.ttf font file.
|
||||
logger (function, optional): A function to log progress and errors. Defaults to print.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
return False
|
||||
|
||||
if not posts_data:
|
||||
logger(" No text content was collected to create a PDF.")
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
raise RuntimeError("Font file not found.")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
|
||||
logger(" PDF may not support all characters. Falling back to default Arial font.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
pdf.set_font('Arial', 'B', 16)
|
||||
|
||||
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for post in posts_data:
|
||||
pdf.add_page()
|
||||
# Post Title
|
||||
pdf.set_font('DejaVu', 'B', 16)
|
||||
|
||||
# vvv THIS LINE IS CORRECTED vvv
|
||||
# We explicitly set align='L' and remove the incorrect positional arguments.
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
|
||||
pdf.ln(5) # Add a little space after the title
|
||||
|
||||
# Post Content
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
93
src/ui/flow_layout.py
Normal file
93
src/ui/flow_layout.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# src/ui/flow_layout.py
|
||||
|
||||
from PyQt5.QtWidgets import QLayout, QSizePolicy, QStyle
|
||||
from PyQt5.QtCore import QPoint, QRect, QSize, Qt
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
"""A custom layout that arranges widgets in a flow, wrapping as necessary."""
|
||||
def __init__(self, parent=None, margin=0, spacing=-1):
|
||||
super(FlowLayout, self).__init__(parent)
|
||||
|
||||
if parent is not None:
|
||||
self.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
self.setSpacing(spacing)
|
||||
self.itemList = []
|
||||
|
||||
def __del__(self):
|
||||
item = self.takeAt(0)
|
||||
while item:
|
||||
item = self.takeAt(0)
|
||||
|
||||
def addItem(self, item):
|
||||
self.itemList.append(item)
|
||||
|
||||
def count(self):
|
||||
return len(self.itemList)
|
||||
|
||||
def itemAt(self, index):
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList[index]
|
||||
return None
|
||||
|
||||
def takeAt(self, index):
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList.pop(index)
|
||||
return None
|
||||
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientations(Qt.Orientation(0))
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
return self._do_layout(QRect(0, 0, width, 0), True)
|
||||
|
||||
def setGeometry(self, rect):
|
||||
super(FlowLayout, self).setGeometry(rect)
|
||||
self._do_layout(rect, False)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
|
||||
margin, _, _, _ = self.getContentsMargins()
|
||||
size += QSize(2 * margin, 2 * margin)
|
||||
return size
|
||||
|
||||
def _do_layout(self, rect, test_only):
|
||||
x = rect.x()
|
||||
y = rect.y()
|
||||
line_height = 0
|
||||
|
||||
space_x = self.spacing()
|
||||
space_y = self.spacing()
|
||||
if self.layout() is not None:
|
||||
space_x = self.spacing()
|
||||
space_y = self.spacing()
|
||||
else:
|
||||
space_x = self.spacing()
|
||||
space_y = self.spacing()
|
||||
|
||||
|
||||
for item in self.itemList:
|
||||
wid = item.widget()
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
if next_x - space_x > rect.right() and line_height > 0:
|
||||
x = rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item.sizeHint().width() + space_x
|
||||
line_height = 0
|
||||
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||||
|
||||
x = next_x
|
||||
line_height = max(line_height, item.sizeHint().height())
|
||||
|
||||
return y + line_height - rect.y()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user