Compare commits
12 Commits
233eed1ca7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 06107a3d78 | |||
| 33e3ed2615 | |||
| 6ed8a79660 | |||
| 56657efa7c | |||
| fa195f2695 | |||
| 988178f1c6 | |||
| 71019bf399 | |||
| 6a5f223a88 | |||
| 274a6778b3 | |||
| 772827082e | |||
| f8a9ad0065 | |||
| 9726a82851 |
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
- name: Build Executable
|
||||
run: |
|
||||
wine pyinstaller --onefile --windowed --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py
|
||||
wine pyinstaller --onefile --windowed --hidden-import=pkgutil --hidden-import=PyQt6.sip --collect-all PyQt6 --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -27,6 +27,9 @@ def build():
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
"--hidden-import=pkgutil",
|
||||
"--hidden-import=PyQt6.sip",
|
||||
"--collect-all", "PyQt6",
|
||||
"--paths", "src",
|
||||
f"--add-data=app_icon.png{sep}.",
|
||||
"src/main.py"
|
||||
|
||||
@@ -228,3 +228,65 @@ def parse_header(data: bytearray):
|
||||
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
|
||||
|
||||
return page_num, sub_code, language
|
||||
|
||||
def save_tti(file_path: str, page: Page):
|
||||
"""
|
||||
Saves a single Page object to a TTI file.
|
||||
"""
|
||||
with open(file_path, 'w', encoding='latin-1') as f:
|
||||
# Header Info
|
||||
# DS - Source? Description?
|
||||
f.write(f"DS,Teletext Editor Export\n")
|
||||
f.write(f"SP,{file_path}\n")
|
||||
|
||||
# PN - Page Number mpp00
|
||||
# Typically TTI uses decimal integer for mppss?
|
||||
# Or mppss as hex digits?
|
||||
# Standard convention: mppss where m is 1-8, pp is 00-FF, ss is 00-99
|
||||
# Example: Page 100 -> PN,10000
|
||||
# Example: Page 1F0 -> PN,1F000
|
||||
f.write(f"PN,{page.magazine}{page.page_number:02X}00\n")
|
||||
|
||||
# SC - Subcode ssss
|
||||
f.write(f"SC,{page.sub_code:04X}\n")
|
||||
|
||||
# PS - Page Status
|
||||
# 8000 is typical for "Transmission"
|
||||
f.write(f"PS,8000\n")
|
||||
|
||||
# RE - Region (Language)
|
||||
f.write(f"RE,{page.language}\n")
|
||||
|
||||
# Lines
|
||||
# We need to construct the 40-char string for each row
|
||||
# Row 0 is special (Header)
|
||||
|
||||
# Get all packets for this page
|
||||
# Map row -> packet
|
||||
rows = {}
|
||||
for p in page.packets:
|
||||
rows[p.row] = p
|
||||
|
||||
for r in range(26): # 0 to 25
|
||||
if r in rows:
|
||||
packet = rows[r]
|
||||
# Packet data is 40 bytes (after the 2-byte header we stripped in Packet class? No wait)
|
||||
# Packet.data in our model IS the 40 bytes of character data (we strip MRAG in __post_init__)
|
||||
# So we just decode it as latin-1 to get the chars
|
||||
|
||||
# However, we must ensure we don't have newlines or nulls breaking the text file structure
|
||||
# TTI format usually accepts raw bytes 0x00-0xFF if strictly handled, but often expects
|
||||
# mapped control codes.
|
||||
# Standard VBIT2 TTI handling treats it as a binary-safe string if mapped to char.
|
||||
|
||||
# Row 0 special handling: The first 8 bytes of Row 0 are usually header flags in the packet,
|
||||
# but visually they are "P100 ".
|
||||
# TTI usually expects the visual representation for the line content.
|
||||
# But for transmission, we want the raw bytes.
|
||||
# OL,r,String
|
||||
|
||||
data_str = packet.data.decode('latin-1')
|
||||
f.write(f"OL,{r},{data_str}\n")
|
||||
else:
|
||||
# Empty line? Usually omitted or written as empty
|
||||
pass
|
||||
|
||||
@@ -252,6 +252,9 @@ class TeletextCanvas(QWidget):
|
||||
held_char = 0x20 # Space
|
||||
double_height = False
|
||||
|
||||
last_visible_idx = -1
|
||||
bg_segments = [(0, bg)] # Track BG changes: (index, color)
|
||||
|
||||
y = row * self.cell_h
|
||||
|
||||
data = b''
|
||||
@@ -263,7 +266,7 @@ class TeletextCanvas(QWidget):
|
||||
# Header string for Row 0 columns 0-7
|
||||
header_prefix = ""
|
||||
if row == 0 and self.page:
|
||||
header_prefix = f"P{self.page.magazine}{self.page.page_number:02d}"
|
||||
header_prefix = f"P{self.page.magazine}{self.page.page_number:02X}"
|
||||
# Pad to 8 chars
|
||||
header_prefix = header_prefix.ljust(8)
|
||||
|
||||
@@ -311,12 +314,33 @@ class TeletextCanvas(QWidget):
|
||||
graphics_mode = True
|
||||
elif byte_val == 0x1C: # Black BG
|
||||
bg = COLORS[0]
|
||||
bg_segments.append((c, bg))
|
||||
elif byte_val == 0x1D: # New BG
|
||||
bg = fg
|
||||
bg_segments.append((c, bg))
|
||||
|
||||
elif byte_val == 0x0C: # Normal Height
|
||||
double_height = False
|
||||
elif byte_val == 0x0D: # Double Height
|
||||
double_height = True
|
||||
# Backfill Height if we are in leading controls
|
||||
if last_visible_idx == -1:
|
||||
# Update occlusion mask for 0..c-1
|
||||
for k in range(c):
|
||||
next_occlusion_mask[k] = True
|
||||
|
||||
# Repaint 0..c-1 with DH
|
||||
if draw_bg:
|
||||
# Repaint each cell with its historical BG
|
||||
for k in range(c):
|
||||
# Resolve BG for k
|
||||
cell_bg = bg_segments[0][1]
|
||||
for idx, color in reversed(bg_segments):
|
||||
if idx <= k:
|
||||
cell_bg = color
|
||||
break
|
||||
painter.fillRect(k * self.cell_w, y, self.cell_w, self.cell_h * 2, cell_bg)
|
||||
|
||||
elif byte_val == 0x19: # Contiguous Graphics
|
||||
contiguous = True
|
||||
elif byte_val == 0x1A: # Separated Graphics
|
||||
@@ -326,6 +350,23 @@ class TeletextCanvas(QWidget):
|
||||
elif byte_val == 0x1F: # Release Graphics
|
||||
hold_graphics = False
|
||||
|
||||
# Update visibility tracker
|
||||
# If it's a control code, it's "invisible" (space) UNLESS Held Graphics draws something
|
||||
visible_content = False
|
||||
if is_control:
|
||||
if hold_graphics and graphics_mode:
|
||||
visible_content = True
|
||||
else:
|
||||
# Treat Space (0x20) as "invisible" for backfill purposes
|
||||
# This allows backfilling height over leading spaces in banners
|
||||
if byte_val > 0x20:
|
||||
visible_content = True
|
||||
|
||||
if visible_content:
|
||||
# If this is the first visible char, mark it
|
||||
if last_visible_idx == -1:
|
||||
last_visible_idx = c
|
||||
|
||||
# Record Double Height for next row
|
||||
if double_height and not is_occluded:
|
||||
next_occlusion_mask[c] = True
|
||||
@@ -398,8 +439,8 @@ class TeletextCanvas(QWidget):
|
||||
if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
|
||||
# Difference with white creates inversion
|
||||
# Note: Cursor follows double height? Probably just the active cell.
|
||||
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
|
||||
h_cursor = self.cell_h * 2 if double_height else self.cell_h
|
||||
painter.fillRect(x, y, self.cell_w, h_cursor, QColor(255, 255, 255))
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
||||
|
||||
return next_occlusion_mask
|
||||
|
||||
@@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (
|
||||
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
|
||||
from PyQt6.QtCore import Qt, QRect, QTimer
|
||||
|
||||
from .io import load_t42, save_t42
|
||||
from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4
|
||||
from .renderer import TeletextCanvas, create_blank_packet
|
||||
import copy
|
||||
import sys
|
||||
@@ -121,10 +121,14 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.service = TeletextService()
|
||||
self.current_page: Page = None
|
||||
self.current_file_path = None
|
||||
self.clipboard = [] # List of (row, data_bytes)
|
||||
self.undo_stack = []
|
||||
self.redo_stack = []
|
||||
self.is_modified = False
|
||||
self.language_overrides = {} # Session-based viewer overrides: (mag, pnum) -> lang_idx
|
||||
|
||||
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
|
||||
|
||||
# UI Components
|
||||
self.central_widget = QWidget()
|
||||
@@ -296,6 +300,26 @@ class MainWindow(QMainWindow):
|
||||
btn_release.clicked.connect(lambda: self.insert_char(0x1F))
|
||||
right_layout.addWidget(btn_release)
|
||||
|
||||
right_layout.addSpacing(10)
|
||||
|
||||
# Page Language Setting
|
||||
lang_group_label = QLabel("Page Language:")
|
||||
right_layout.addWidget(lang_group_label)
|
||||
|
||||
lang_layout = QHBoxLayout()
|
||||
self.lang_combo = QComboBox()
|
||||
self.lang_combo.addItems(self.language_names)
|
||||
self.lang_combo.setToolTip("Select the National Option Character Set for this page.")
|
||||
|
||||
btn_set_lang = QPushButton("Set")
|
||||
btn_set_lang.setFixedWidth(40)
|
||||
btn_set_lang.clicked.connect(self.apply_language_change)
|
||||
btn_set_lang.setToolTip("Apply this language setting to the page header.")
|
||||
|
||||
lang_layout.addWidget(self.lang_combo)
|
||||
lang_layout.addWidget(btn_set_lang)
|
||||
right_layout.addLayout(lang_layout)
|
||||
|
||||
right_layout.addStretch()
|
||||
|
||||
self.layout.addLayout(center_layout, 1)
|
||||
@@ -319,8 +343,6 @@ class MainWindow(QMainWindow):
|
||||
self.language_label = QLabel("Lang: English")
|
||||
self.status_bar.addPermanentWidget(self.language_label)
|
||||
|
||||
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
|
||||
|
||||
# Menus
|
||||
self.create_menus()
|
||||
|
||||
@@ -328,6 +350,7 @@ class MainWindow(QMainWindow):
|
||||
idx = self.canvas.subset_idx
|
||||
if 0 <= idx < len(self.language_names):
|
||||
self.language_label.setText(f"Lang: {self.language_names[idx]}")
|
||||
self.lang_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.language_label.setText(f"Lang: Unknown ({idx})")
|
||||
|
||||
@@ -386,6 +409,10 @@ class MainWindow(QMainWindow):
|
||||
save_as_action.triggered.connect(self.save_as_file)
|
||||
file_menu.addAction(save_as_action)
|
||||
|
||||
export_tti_action = QAction("Export to TTI...", self)
|
||||
export_tti_action.triggered.connect(self.export_tti)
|
||||
file_menu.addAction(export_tti_action)
|
||||
|
||||
close_action = QAction("Close File", self)
|
||||
close_action.triggered.connect(self.close_file)
|
||||
file_menu.addAction(close_action)
|
||||
@@ -437,6 +464,79 @@ class MainWindow(QMainWindow):
|
||||
if action:
|
||||
idx = action.data()
|
||||
self.canvas.subset_idx = idx
|
||||
|
||||
# Store session override for the current page (magazine + page number)
|
||||
if self.current_page:
|
||||
key = (self.current_page.magazine, self.current_page.page_number)
|
||||
self.language_overrides[key] = idx
|
||||
|
||||
self.canvas.redraw()
|
||||
self.canvas.update()
|
||||
self.update_language_label()
|
||||
|
||||
def apply_language_change(self):
|
||||
if not self.current_page:
|
||||
return
|
||||
|
||||
idx = self.lang_combo.currentIndex()
|
||||
if idx < 0: return
|
||||
|
||||
# Update the model
|
||||
self.current_page.language = idx
|
||||
|
||||
# Also update the session override/viewer to match
|
||||
self.canvas.subset_idx = idx
|
||||
key = (self.current_page.magazine, self.current_page.page_number)
|
||||
self.language_overrides[key] = idx
|
||||
|
||||
# Patch Row 0 packet data to persist language selection to file
|
||||
# Language bits are in Byte 8 (Control Bits 2): C12, C13, C14
|
||||
# We need to preserve C11 (bit 3 of encoded 4-bit val) which is "Inhibit Display" usually 0
|
||||
|
||||
# Find Row 0 packet
|
||||
header_packet = None
|
||||
for p in self.current_page.packets:
|
||||
if p.row == 0:
|
||||
header_packet = p
|
||||
break
|
||||
|
||||
if header_packet and len(header_packet.data) > 8:
|
||||
try:
|
||||
old_val = decode_hamming_8_4(header_packet.data[8])
|
||||
# Encoded nibble structure: D1(b0), D2(b1), D3(b2), D4(b3)
|
||||
# D1 maps to C12
|
||||
# D2 maps to C13
|
||||
# D3 maps to C14
|
||||
# D4 maps to C11
|
||||
|
||||
# io.py logic for reading:
|
||||
# language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
|
||||
# i.e. Lang Bit 0 comes from D2, Lang Bit 1 comes from D1, Lang Bit 2 comes from D3
|
||||
|
||||
# So for writing:
|
||||
# D1 = Lang Bit 1
|
||||
# D2 = Lang Bit 0
|
||||
# D3 = Lang Bit 2
|
||||
|
||||
l0 = (idx >> 0) & 1
|
||||
l1 = (idx >> 1) & 1
|
||||
l2 = (idx >> 2) & 1
|
||||
|
||||
d1 = l1
|
||||
d2 = l0
|
||||
d3 = l2
|
||||
d4 = (old_val >> 3) & 1 # Preserve C11
|
||||
|
||||
new_val = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3)
|
||||
|
||||
header_packet.data[8] = encode_hamming_8_4(new_val)
|
||||
self.set_modified(True)
|
||||
self.status_label.setText(f"Language set to {self.language_names[idx]} (saved to header).")
|
||||
except Exception as e:
|
||||
self.status_label.setText(f"Error setting language: {e}")
|
||||
else:
|
||||
self.status_label.setText("No header packet found to update.")
|
||||
|
||||
self.canvas.redraw()
|
||||
self.canvas.update()
|
||||
self.update_language_label()
|
||||
@@ -475,6 +575,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.undo_stack.clear()
|
||||
self.redo_stack.clear()
|
||||
self.language_overrides.clear()
|
||||
|
||||
self.status_label.setText("File closed.")
|
||||
QTimer.singleShot(3000, lambda: self.status_label.setText("Ready"))
|
||||
@@ -516,9 +617,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def save_file(self) -> bool:
|
||||
if not self.current_file_path:
|
||||
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)")
|
||||
if not fname: return False
|
||||
self.current_file_path = fname
|
||||
# User requested status message instead of Save As behavior for empty state
|
||||
self.status_label.setText("No file loaded to save. Please use 'Save As...' or 'Open' first.")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.progress_bar.setVisible(True)
|
||||
@@ -544,6 +645,20 @@ class MainWindow(QMainWindow):
|
||||
self.status_label.setText("Error saving file")
|
||||
return False
|
||||
|
||||
def export_tti(self):
|
||||
if not self.current_page:
|
||||
QMessageBox.warning(self, "Export TTI", "No page selected to export.")
|
||||
return
|
||||
|
||||
fname, _ = QFileDialog.getSaveFileName(self, "Export to TTI", "", "Teletext Text Image (*.tti)")
|
||||
if not fname: return
|
||||
|
||||
try:
|
||||
save_tti(fname, self.current_page)
|
||||
QMessageBox.information(self, "Export Successful", f"Page exported to {os.path.basename(fname)}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}")
|
||||
|
||||
def copy_page_content(self):
|
||||
if not self.current_page:
|
||||
return
|
||||
@@ -751,6 +866,14 @@ class MainWindow(QMainWindow):
|
||||
if isinstance(page, Page):
|
||||
self.current_page = page
|
||||
self.canvas.set_page(page)
|
||||
|
||||
# Apply session language override if it exists for this page
|
||||
key = (page.magazine, page.page_number)
|
||||
if key in self.language_overrides:
|
||||
self.canvas.subset_idx = self.language_overrides[key]
|
||||
self.canvas.redraw()
|
||||
self.canvas.update()
|
||||
|
||||
self.update_language_label()
|
||||
self.canvas.setFocus()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user