Compare commits
10 Commits
7f9d8304be
...
42e189635b
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e189635b | |||
| 4b7b73e9a3 | |||
| 8c393c8f9e | |||
| 783e5006f7 | |||
| 944556f259 | |||
| dedabcd12a | |||
| 67840ad899 | |||
| 1cdc35850a | |||
| 66cb788fb0 | |||
| 132dc50de8 |
Binary file not shown.
@@ -5,12 +5,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build Linux
|
name: Build Linux
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
steps:
|
||||||
- name: Configure Git Redirect
|
|
||||||
run: git config --global url."http://192.168.50.24:3333/".insteadOf "http://server:3000/"
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
@@ -21,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
python --version
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Build Executable
|
- name: Build Executable
|
||||||
|
|||||||
@@ -4,26 +4,21 @@ on: [push, pull_request]
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Windows
|
name: Build Windows
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: tobix/pywine:3.10
|
||||||
steps:
|
steps:
|
||||||
- name: Configure Git Redirect
|
|
||||||
run: git config --global url."http://192.168.50.24:3000/".insteadOf "http://server:3000/"
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.10'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt
|
wine python -m pip install --upgrade pip
|
||||||
|
wine pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Build Executable
|
- name: Build Executable
|
||||||
run: |
|
run: |
|
||||||
pyinstaller --onefile --windowed --name TeletextEditor_Windows.exe --paths src src/main.py
|
wine pyinstaller --onefile --windowed --name TeletextEditor_Windows.exe --paths src src/main.py
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
*.t42
|
||||||
60
build_app.py
Normal file
60
build_app.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import platform
|
||||||
|
|
||||||
|
def clean_build_dirs():
|
||||||
|
"""Removes build and dist directories if they exist."""
|
||||||
|
for d in ["build", "dist"]:
|
||||||
|
if os.path.exists(d):
|
||||||
|
print(f"Cleaning {d}...")
|
||||||
|
shutil.rmtree(d)
|
||||||
|
|
||||||
|
spec_file = "TeletextEditor_Linux.spec" if platform.system() == "Linux" else "TeletextEditor_Windows.spec"
|
||||||
|
if os.path.exists(spec_file):
|
||||||
|
os.remove(spec_file)
|
||||||
|
|
||||||
|
def build():
|
||||||
|
system = platform.system()
|
||||||
|
print(f"Detected OS: {system}")
|
||||||
|
|
||||||
|
base_cmd = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--onefile",
|
||||||
|
"--windowed",
|
||||||
|
"--paths", "src",
|
||||||
|
"src/main.py"
|
||||||
|
]
|
||||||
|
|
||||||
|
if system == "Linux":
|
||||||
|
name = "TeletextEditor_Linux"
|
||||||
|
elif system == "Windows":
|
||||||
|
name = "TeletextEditor_Windows.exe"
|
||||||
|
else:
|
||||||
|
print(f"Unsupported platform: {system}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = base_cmd + ["--name", name]
|
||||||
|
|
||||||
|
print("Running build command:")
|
||||||
|
print(" ".join(cmd))
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
print("\n" + "="*40)
|
||||||
|
print(f"Build successful! Executable is in 'dist/{name}'")
|
||||||
|
print("="*40)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Build failed with error code {e.returncode}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Cross-compilation note
|
||||||
|
if system == "Linux":
|
||||||
|
print("\nNote: To build the Windows executable, please run this script on Windows.")
|
||||||
|
elif system == "Windows":
|
||||||
|
print("\nNote: To build the Linux executable, please run this script on Linux.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
clean_build_dirs()
|
||||||
|
build()
|
||||||
28
check_ttx6.py
Normal file
28
check_ttx6.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.append(os.path.join(os.getcwd(), 'src'))
|
||||||
|
|
||||||
|
from teletext.io import load_t42
|
||||||
|
|
||||||
|
def check_file(filename):
|
||||||
|
if not os.path.exists(filename):
|
||||||
|
print(f"File {filename} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
service = load_t42(filename)
|
||||||
|
print(f"Analysis of {filename}:")
|
||||||
|
print(f"Total packets: {len(service.all_packets)}")
|
||||||
|
print(f"Total pages: {len(service.pages)}")
|
||||||
|
|
||||||
|
language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
|
||||||
|
|
||||||
|
for i, page in enumerate(service.pages):
|
||||||
|
lang_idx = page.language
|
||||||
|
lang_name = language_names[lang_idx] if 0 <= lang_idx < len(language_names) else f"Unknown ({lang_idx})"
|
||||||
|
print(f"Page {i+1}: Mag {page.magazine} Num {page.page_number:02d}, Lang: {lang_idx} ({lang_name})")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_file("TTX-6_RAW.t42")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,10 +15,10 @@ ENGLISH = {
|
|||||||
|
|
||||||
# Swedish/Finnish/Hungarian - Option 010 (2)
|
# Swedish/Finnish/Hungarian - Option 010 (2)
|
||||||
SWEDISH_FINNISH = {
|
SWEDISH_FINNISH = {
|
||||||
0x23: '#', 0x24: '¤', 0x40: 'É',
|
0x23: '#', 0x24: '\u00A4', 0x40: '\u00C9',
|
||||||
0x5B: 'Ä', 0x5C: 'Ö', 0x5D: 'Å', 0x5E: 'Ü',
|
0x5B: '\u00C4', 0x5C: '\u00D6', 0x5D: '\u00C5', 0x5E: '\u00DC',
|
||||||
0x5F: '_', 0x60: 'é',
|
0x5F: '_', 0x60: '\u00E9',
|
||||||
0x7B: 'ä', 0x7C: 'ö', 0x7D: 'å', 0x7E: 'ü'
|
0x7B: '\u00E4', 0x7C: '\u00F6', 0x7D: '\u00E5', 0x7E: '\u00FC'
|
||||||
}
|
}
|
||||||
|
|
||||||
# German - Option 001 (1)
|
# German - Option 001 (1)
|
||||||
@@ -58,3 +58,22 @@ def get_char(byte_val, subset_idx):
|
|||||||
return mapping[valid_byte]
|
return mapping[valid_byte]
|
||||||
|
|
||||||
return chr(valid_byte)
|
return chr(valid_byte)
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
def get_byte_from_char(char, subset_idx):
|
||||||
|
if len(char) != 1: return 0
|
||||||
|
|
||||||
|
# Normalize input to NFC to match our map keys (if they are NFC, which python literals usually are)
|
||||||
|
char = unicodedata.normalize('NFC', char)
|
||||||
|
|
||||||
|
if subset_idx < 0 or subset_idx >= len(SETS):
|
||||||
|
subset_idx = 0
|
||||||
|
|
||||||
|
mapping = SETS[subset_idx]
|
||||||
|
|
||||||
|
for code, mapped_char in mapping.items():
|
||||||
|
if mapped_char == char:
|
||||||
|
return code
|
||||||
|
|
||||||
|
return ord(char)
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ def load_t42(file_path: str, progress_callback: Optional[Callable[[int, int], No
|
|||||||
# or find the existing one if we want to support updates (but T42 usually is a stream capture).
|
# or find the existing one if we want to support updates (but T42 usually is a stream capture).
|
||||||
# If it's an editor file, it's likely sequential.
|
# If it's an editor file, it's likely sequential.
|
||||||
|
|
||||||
p_num, sub_code = parse_header(packet.data)
|
p_num, sub_code, language = parse_header(packet.data)
|
||||||
|
|
||||||
# Create new page
|
# Create new page
|
||||||
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code)
|
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code, language=language)
|
||||||
new_page.packets.append(packet)
|
new_page.packets.append(packet)
|
||||||
service.pages.append(new_page)
|
service.pages.append(new_page)
|
||||||
else:
|
else:
|
||||||
@@ -191,7 +191,13 @@ def parse_header(data: bytearray):
|
|||||||
pu = decode_hamming_8_4(data[0])
|
pu = decode_hamming_8_4(data[0])
|
||||||
pt = decode_hamming_8_4(data[1])
|
pt = decode_hamming_8_4(data[1])
|
||||||
|
|
||||||
page_num = (pt & 0xF) * 10 + (pu & 0xF)
|
# Use BCD/Hex-like storage: High nibble is Tens, Low nibble is Units.
|
||||||
|
# This preserves Hex pages (A-F) without colliding with decimal pages.
|
||||||
|
# E.g. Page 1FF -> Tens=F(15), Units=F(15) -> 0xFF (255)
|
||||||
|
# Page 12E -> Tens=2, Units=E(14) -> 0x2E (46)
|
||||||
|
# Page 134 -> Tens=3, Units=4 -> 0x34 (52)
|
||||||
|
# 0x2E != 0x34. No collision.
|
||||||
|
page_num = ((pt & 0xF) << 4) | (pu & 0xF)
|
||||||
|
|
||||||
# Subcode: S1, S2, S3, S4
|
# Subcode: S1, S2, S3, S4
|
||||||
# S1 (low), S2, S3, S4 (high)
|
# S1 (low), S2, S3, S4 (high)
|
||||||
@@ -209,4 +215,16 @@ def parse_header(data: bytearray):
|
|||||||
|
|
||||||
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12)
|
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12)
|
||||||
|
|
||||||
return page_num, sub_code
|
# Control bits C12, C13, C14 are in Byte 8 (index 8)
|
||||||
|
# They determine the National Option (Language)
|
||||||
|
c_bits_2 = decode_hamming_8_4(data[8])
|
||||||
|
|
||||||
|
# Fix for Language Detection:
|
||||||
|
# It seems C12 and C13 are swapped in the Hamming decoding or file format relative to expected values.
|
||||||
|
# C12 is bit 0, C13 is bit 1.
|
||||||
|
# We swap them so D1 maps to C13 (Swedish bit) and D2 maps to C12 (German bit).
|
||||||
|
# Original: language = c_bits_2 & 0b111
|
||||||
|
|
||||||
|
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
|
||||||
|
|
||||||
|
return page_num, sub_code, language
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ class Page:
|
|||||||
magazine: int
|
magazine: int
|
||||||
page_number: int # 00-99
|
page_number: int # 00-99
|
||||||
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent)
|
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent)
|
||||||
|
language: int = 0 # National Option (0-7)
|
||||||
packets: List[Packet] = field(default_factory=list)
|
packets: List[Packet] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_page_number(self):
|
def full_page_number(self):
|
||||||
return f"{self.magazine}{self.page_number:02d}"
|
# Format as Hex to support A-F pages
|
||||||
|
return f"{self.magazine}{self.page_number:02X}"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TeletextService:
|
class TeletextService:
|
||||||
|
|||||||
136
src/teletext/renderer.py
Normal file → Executable file
136
src/teletext/renderer.py
Normal file → Executable file
@@ -4,7 +4,7 @@ from PyQt6.QtGui import QPainter, QColor, QFont, QImage, QBrush, QPen
|
|||||||
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
|
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
|
||||||
|
|
||||||
from .models import Page, Packet
|
from .models import Page, Packet
|
||||||
from .charsets import get_char
|
from .charsets import get_char, get_byte_from_char
|
||||||
|
|
||||||
# Helper to create a blank packet
|
# Helper to create a blank packet
|
||||||
def create_blank_packet(magazine: int, row: int) -> Packet:
|
def create_blank_packet(magazine: int, row: int) -> Packet:
|
||||||
@@ -58,7 +58,7 @@ class TeletextCanvas(QWidget):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setMouseTracking(True) # Just in case
|
self.setMouseTracking(True) # Just in case
|
||||||
self.setMinimumSize(480, 500) # 40x12 * 25x20 approx
|
self.setMinimumSize(800, 600) # 40x20 * 25x24
|
||||||
self.page: Page = None
|
self.page: Page = None
|
||||||
self.subset_idx = 0 # Default English
|
self.subset_idx = 0 # Default English
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@ class TeletextCanvas(QWidget):
|
|||||||
# We will render to a fixed size QImage and scale it
|
# We will render to a fixed size QImage and scale it
|
||||||
self.cols = 40
|
self.cols = 40
|
||||||
self.rows = 25
|
self.rows = 25
|
||||||
self.cell_w = 12
|
self.cell_w = 20
|
||||||
self.cell_h = 20
|
self.cell_h = 24
|
||||||
self.img_w = self.cols * self.cell_w
|
self.img_w = self.cols * self.cell_w
|
||||||
self.img_h = self.rows * self.cell_h
|
self.img_h = self.rows * self.cell_h
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class TeletextCanvas(QWidget):
|
|||||||
self.buffer.fill(Qt.GlobalColor.black)
|
self.buffer.fill(Qt.GlobalColor.black)
|
||||||
|
|
||||||
# Font for text
|
# Font for text
|
||||||
self.font = QFont("Courier New", 14)
|
self.font = QFont("Courier New", 18)
|
||||||
self.font.setStyleHint(QFont.StyleHint.Monospace)
|
self.font.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
self.font.setBold(True)
|
self.font.setBold(True)
|
||||||
|
|
||||||
@@ -139,6 +139,12 @@ class TeletextCanvas(QWidget):
|
|||||||
|
|
||||||
def set_page(self, page: Page):
|
def set_page(self, page: Page):
|
||||||
self.page = page
|
self.page = page
|
||||||
|
# Set language from page header
|
||||||
|
if page:
|
||||||
|
self.subset_idx = page.language
|
||||||
|
else:
|
||||||
|
self.subset_idx = 0
|
||||||
|
|
||||||
self.cursor_x = 0
|
self.cursor_x = 0
|
||||||
self.cursor_y = 0
|
self.cursor_y = 0
|
||||||
self.redraw()
|
self.redraw()
|
||||||
@@ -168,7 +174,8 @@ class TeletextCanvas(QWidget):
|
|||||||
|
|
||||||
# Check if text is a single char
|
# Check if text is a single char
|
||||||
if len(text) == 1:
|
if len(text) == 1:
|
||||||
byte_val = ord(text)
|
byte_val = get_byte_from_char(text, self.subset_idx)
|
||||||
|
|
||||||
# Simple filter
|
# Simple filter
|
||||||
if byte_val > 255: byte_val = 0x3F # ?
|
if byte_val > 255: byte_val = 0x3F # ?
|
||||||
|
|
||||||
@@ -188,7 +195,7 @@ class TeletextCanvas(QWidget):
|
|||||||
# But for sanity, let's just append.
|
# But for sanity, let's just append.
|
||||||
|
|
||||||
# Write the char
|
# Write the char
|
||||||
byte_val = ord(text)
|
byte_val = get_byte_from_char(text, self.subset_idx)
|
||||||
if byte_val > 255: byte_val = 0x3F
|
if byte_val > 255: byte_val = 0x3F
|
||||||
new_packet.data[self.cursor_x] = byte_val
|
new_packet.data[self.cursor_x] = byte_val
|
||||||
|
|
||||||
@@ -214,13 +221,27 @@ class TeletextCanvas(QWidget):
|
|||||||
if 0 <= p.row <= 25:
|
if 0 <= p.row <= 25:
|
||||||
grid[p.row] = p
|
grid[p.row] = p
|
||||||
|
|
||||||
|
# Pass 1: Backgrounds
|
||||||
|
occlusion_mask = [False] * 40
|
||||||
for r in range(25):
|
for r in range(25):
|
||||||
packet = grid[r]
|
packet = grid[r]
|
||||||
self.draw_row(painter, r, packet)
|
occlusion_mask = self.draw_row(painter, r, packet, draw_bg=True, draw_fg=False, occlusion_mask=occlusion_mask)
|
||||||
|
|
||||||
|
# Pass 2: Foregrounds
|
||||||
|
occlusion_mask = [False] * 40
|
||||||
|
for r in range(25):
|
||||||
|
packet = grid[r]
|
||||||
|
occlusion_mask = self.draw_row(painter, r, packet, draw_bg=False, draw_fg=True, occlusion_mask=occlusion_mask)
|
||||||
|
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
def draw_row(self, painter, row, packet):
|
def draw_row(self, painter, row, packet, draw_bg=True, draw_fg=True, occlusion_mask=None):
|
||||||
|
if occlusion_mask is None:
|
||||||
|
occlusion_mask = [False] * 40
|
||||||
|
|
||||||
|
# Output mask for the next row
|
||||||
|
next_occlusion_mask = [False] * 40
|
||||||
|
|
||||||
# Default State at start of row
|
# Default State at start of row
|
||||||
fg = COLORS[7] # White
|
fg = COLORS[7] # White
|
||||||
bg = COLORS[0] # Black
|
bg = COLORS[0] # Black
|
||||||
@@ -228,6 +249,7 @@ class TeletextCanvas(QWidget):
|
|||||||
contiguous = True # Mosaic
|
contiguous = True # Mosaic
|
||||||
hold_graphics = False
|
hold_graphics = False
|
||||||
held_char = 0x20 # Space
|
held_char = 0x20 # Space
|
||||||
|
double_height = False
|
||||||
|
|
||||||
y = row * self.cell_h
|
y = row * self.cell_h
|
||||||
|
|
||||||
@@ -247,6 +269,24 @@ class TeletextCanvas(QWidget):
|
|||||||
for c in range(40):
|
for c in range(40):
|
||||||
x = c * self.cell_w
|
x = c * self.cell_w
|
||||||
|
|
||||||
|
# If this cell is occluded by the row above, skip drawing and attribute processing?
|
||||||
|
# Spec says "The characters in the row below are ignored."
|
||||||
|
# Ideally we shouldn't even process attributes, but for simple renderer we just skip draw.
|
||||||
|
# However, if we skip attribute processing, state (fg/bg) won't update.
|
||||||
|
# Teletext attributes are serial.
|
||||||
|
# BUT, if the row above covers it, the viewer sees the row above.
|
||||||
|
# Does the hidden content affect the *rest* of the row?
|
||||||
|
# Likely yes, attributes usually propagate.
|
||||||
|
# But the spec says "ignored". Let's assume we skip *everything* for this cell visually,
|
||||||
|
# but maybe we should technically maintain state?
|
||||||
|
# For "Double Height" visual correctness, skipping drawing is the key.
|
||||||
|
# We will Process attributes (to keep state consistent) but Skip Drawing if occluded.
|
||||||
|
|
||||||
|
# Wait, if we process attributes, we might set double_height=True for the NEXT row?
|
||||||
|
# If this cell is occluded, it shouldn't trigger DH for the next row.
|
||||||
|
|
||||||
|
is_occluded = occlusion_mask[c]
|
||||||
|
|
||||||
# Decide byte value
|
# Decide byte value
|
||||||
if row == 0 and c < 8:
|
if row == 0 and c < 8:
|
||||||
# Use generated header prefix
|
# Use generated header prefix
|
||||||
@@ -273,9 +313,9 @@ class TeletextCanvas(QWidget):
|
|||||||
elif byte_val == 0x1D: # New BG
|
elif byte_val == 0x1D: # New BG
|
||||||
bg = fg
|
bg = fg
|
||||||
elif byte_val == 0x0C: # Normal Height
|
elif byte_val == 0x0C: # Normal Height
|
||||||
pass
|
double_height = False
|
||||||
elif byte_val == 0x0D: # Double Height
|
elif byte_val == 0x0D: # Double Height
|
||||||
pass # Not implemented yet
|
double_height = True
|
||||||
elif byte_val == 0x19: # Contiguous Graphics
|
elif byte_val == 0x19: # Contiguous Graphics
|
||||||
contiguous = True
|
contiguous = True
|
||||||
elif byte_val == 0x1A: # Separated Graphics
|
elif byte_val == 0x1A: # Separated Graphics
|
||||||
@@ -285,14 +325,33 @@ class TeletextCanvas(QWidget):
|
|||||||
elif byte_val == 0x1F: # Release Graphics
|
elif byte_val == 0x1F: # Release Graphics
|
||||||
hold_graphics = False
|
hold_graphics = False
|
||||||
|
|
||||||
|
# Record Double Height for next row
|
||||||
|
if double_height and not is_occluded:
|
||||||
|
next_occlusion_mask[c] = True
|
||||||
|
|
||||||
|
# If occluded, do not draw anything for this cell
|
||||||
|
if is_occluded:
|
||||||
|
continue
|
||||||
|
|
||||||
# Draw Background
|
# Draw Background
|
||||||
painter.fillRect(x, y, self.cell_w, self.cell_h, bg)
|
if draw_bg:
|
||||||
|
# If double height, draw taller background
|
||||||
|
h_bg = self.cell_h * 2 if double_height else self.cell_h
|
||||||
|
painter.fillRect(x, y, self.cell_w, h_bg, bg)
|
||||||
|
|
||||||
# Draw Foreground
|
# Draw Foreground
|
||||||
|
if draw_fg:
|
||||||
|
# Calculate height
|
||||||
|
# For Mosaics, we use the height param.
|
||||||
|
# For Alphanumerics, we scale the painter.
|
||||||
|
|
||||||
if is_control:
|
if is_control:
|
||||||
# "Set-at" spacing attribute? Teletext control codes occupy a space
|
# "Set-at" spacing attribute? Teletext control codes occupy a space
|
||||||
# unless "Hold Graphics" replaces it with previous graphic char.
|
# unless "Hold Graphics" replaces it with previous graphic char.
|
||||||
if hold_graphics and graphics_mode:
|
if hold_graphics and graphics_mode:
|
||||||
|
if double_height:
|
||||||
|
self.draw_mosaic(painter, x, y, held_char, fg, contiguous, height=self.cell_h * 2)
|
||||||
|
else:
|
||||||
self.draw_mosaic(painter, x, y, held_char, fg, contiguous)
|
self.draw_mosaic(painter, x, y, held_char, fg, contiguous)
|
||||||
else:
|
else:
|
||||||
# Draw space (nothing, since we filled BG)
|
# Draw space (nothing, since we filled BG)
|
||||||
@@ -300,28 +359,45 @@ class TeletextCanvas(QWidget):
|
|||||||
else:
|
else:
|
||||||
if graphics_mode:
|
if graphics_mode:
|
||||||
# Mosaic Graphics
|
# Mosaic Graphics
|
||||||
|
h_mos = self.cell_h * 2 if double_height else self.cell_h
|
||||||
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
|
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
|
||||||
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous)
|
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous, height=h_mos)
|
||||||
held_char = byte_val
|
held_char = byte_val
|
||||||
else:
|
else:
|
||||||
# Capital letter in graphics mode? Usually shows char?
|
# Capital letter in graphics mode? Usually shows char?
|
||||||
char = get_char(byte_val, self.subset_idx)
|
char = get_char(byte_val, self.subset_idx)
|
||||||
painter.setPen(fg)
|
painter.setPen(fg)
|
||||||
|
if double_height:
|
||||||
|
painter.save()
|
||||||
|
painter.translate(x, y)
|
||||||
|
painter.scale(1, 2)
|
||||||
|
painter.drawText(QRect(0, 0, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||||
|
painter.restore()
|
||||||
|
else:
|
||||||
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||||
held_char = 0x20
|
held_char = 0x20
|
||||||
else:
|
else:
|
||||||
# Alphanumeric
|
# Alphanumeric
|
||||||
char = get_char(byte_val, self.subset_idx)
|
char = get_char(byte_val, self.subset_idx)
|
||||||
painter.setPen(fg)
|
painter.setPen(fg)
|
||||||
|
if double_height:
|
||||||
|
painter.save()
|
||||||
|
painter.translate(x, y)
|
||||||
|
painter.scale(1, 2)
|
||||||
|
painter.drawText(QRect(0, 0, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||||
|
painter.restore()
|
||||||
|
else:
|
||||||
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||||
# Draw Cursor
|
# Draw Cursor
|
||||||
# Invert the cell at cursor position
|
# Invert the cell at cursor position
|
||||||
if self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
|
if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
|
||||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
|
||||||
# Difference with white creates inversion
|
# 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))
|
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
|
||||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
||||||
|
|
||||||
|
return next_occlusion_mask
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
# Calculate cell from mouse position
|
# Calculate cell from mouse position
|
||||||
@@ -359,13 +435,21 @@ class TeletextCanvas(QWidget):
|
|||||||
row = int(my / (self.cell_h * scale))
|
row = int(my / (self.cell_h * scale))
|
||||||
self.set_cursor(col, row)
|
self.set_cursor(col, row)
|
||||||
|
|
||||||
def draw_mosaic(self, painter, x, y, char_code, color, contiguous):
|
def draw_mosaic(self, painter, x, y, char_code, color, contiguous, height=None):
|
||||||
|
if height is None:
|
||||||
|
height = self.cell_h
|
||||||
|
|
||||||
val = char_code & 0x7F
|
val = char_code & 0x7F
|
||||||
bits = 0
|
bits = 0
|
||||||
if val >= 0x20:
|
if val >= 0x20:
|
||||||
bits = val - 0x20
|
bits = val - 0x20
|
||||||
|
|
||||||
blocks = [
|
# Grid definitions for 2x3 grid
|
||||||
|
x_splits = [0, int(self.cell_w / 2), self.cell_w]
|
||||||
|
y_splits = [0, int(height / 3), int(2 * height / 3), height]
|
||||||
|
|
||||||
|
# Block indices (col, row) for the 6 bits
|
||||||
|
block_indices = [
|
||||||
(0, 0), (1, 0), # Top
|
(0, 0), (1, 0), # Top
|
||||||
(0, 1), (1, 1), # Mid
|
(0, 1), (1, 1), # Mid
|
||||||
(0, 2), (1, 2) # Bot
|
(0, 2), (1, 2) # Bot
|
||||||
@@ -373,24 +457,26 @@ class TeletextCanvas(QWidget):
|
|||||||
|
|
||||||
bit_mask = [1, 2, 4, 8, 16, 64] # 64 is bit 6
|
bit_mask = [1, 2, 4, 8, 16, 64] # 64 is bit 6
|
||||||
|
|
||||||
bw = self.cell_w / 2
|
|
||||||
bh = self.cell_h / 3
|
|
||||||
|
|
||||||
if not contiguous:
|
|
||||||
bw -= 1
|
|
||||||
bh -= 1
|
|
||||||
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(QBrush(color))
|
painter.setBrush(QBrush(color))
|
||||||
|
|
||||||
for i in range(6):
|
for i in range(6):
|
||||||
if bits & bit_mask[i]:
|
if bits & bit_mask[i]:
|
||||||
bx = x + blocks[i][0] * (self.cell_w / 2)
|
c, r = block_indices[i]
|
||||||
by = y + blocks[i][1] * (self.cell_h / 3)
|
|
||||||
|
bx_local = x_splits[c]
|
||||||
|
by_local = y_splits[r]
|
||||||
|
bw = x_splits[c+1] - x_splits[c]
|
||||||
|
bh = y_splits[r+1] - y_splits[r]
|
||||||
|
|
||||||
|
bx = x + bx_local
|
||||||
|
by = y + by_local
|
||||||
|
|
||||||
if not contiguous:
|
if not contiguous:
|
||||||
bx += 1
|
bx += 1
|
||||||
by += 1
|
by += 1
|
||||||
|
bw -= 1
|
||||||
|
bh -= 1
|
||||||
|
|
||||||
painter.drawRect(QRect(int(bx), int(by), int(bw), int(bh)))
|
painter.drawRect(QRect(int(bx), int(by), int(bw), int(bh)))
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,21 @@ class MainWindow(QMainWindow):
|
|||||||
self.status_label = QLabel("Ready")
|
self.status_label = QLabel("Ready")
|
||||||
self.status_bar.addWidget(self.status_label)
|
self.status_bar.addWidget(self.status_label)
|
||||||
|
|
||||||
|
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
|
# Menus
|
||||||
self.create_menus()
|
self.create_menus()
|
||||||
|
|
||||||
|
def update_language_label(self):
|
||||||
|
idx = self.canvas.subset_idx
|
||||||
|
if 0 <= idx < len(self.language_names):
|
||||||
|
self.language_label.setText(f"Lang: {self.language_names[idx]}")
|
||||||
|
else:
|
||||||
|
self.language_label.setText(f"Lang: Unknown ({idx})")
|
||||||
|
|
||||||
def set_modified(self, modified: bool):
|
def set_modified(self, modified: bool):
|
||||||
self.is_modified = modified
|
self.is_modified = modified
|
||||||
title = "Teletext Editor"
|
title = "Teletext Editor"
|
||||||
@@ -152,8 +164,7 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
|
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
|
||||||
|
|
||||||
if ret == QMessageBox.StandardButton.Save:
|
if ret == QMessageBox.StandardButton.Save:
|
||||||
self.save_file()
|
return self.save_file()
|
||||||
return True # check if save succeeded? save_file catches exceptions but we might want to check
|
|
||||||
elif ret == QMessageBox.StandardButton.Discard:
|
elif ret == QMessageBox.StandardButton.Discard:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -223,8 +234,7 @@ class MainWindow(QMainWindow):
|
|||||||
view_menu = menu_bar.addMenu("View")
|
view_menu = menu_bar.addMenu("View")
|
||||||
|
|
||||||
lang_menu = view_menu.addMenu("Language")
|
lang_menu = view_menu.addMenu("Language")
|
||||||
langs = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
|
for i, lang in enumerate(self.language_names):
|
||||||
for i, lang in enumerate(langs):
|
|
||||||
action = QAction(lang, self)
|
action = QAction(lang, self)
|
||||||
action.setData(i)
|
action.setData(i)
|
||||||
action.triggered.connect(self.set_language)
|
action.triggered.connect(self.set_language)
|
||||||
@@ -237,6 +247,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.canvas.subset_idx = idx
|
self.canvas.subset_idx = idx
|
||||||
self.canvas.redraw()
|
self.canvas.redraw()
|
||||||
self.canvas.update()
|
self.canvas.update()
|
||||||
|
self.update_language_label()
|
||||||
|
|
||||||
def prev_subpage(self):
|
def prev_subpage(self):
|
||||||
count = self.subpage_combo.count()
|
count = self.subpage_combo.count()
|
||||||
@@ -311,25 +322,35 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_file_path = fname
|
self.current_file_path = fname
|
||||||
self.save_file()
|
self.save_file()
|
||||||
|
|
||||||
def save_file(self):
|
def save_file(self) -> bool:
|
||||||
if not self.current_file_path:
|
if not self.current_file_path:
|
||||||
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)")
|
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)")
|
||||||
if not fname: return
|
if not fname: return False
|
||||||
self.current_file_path = fname
|
self.current_file_path = fname
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.progress_bar.setVisible(True)
|
self.progress_bar.setVisible(True)
|
||||||
self.status_label.setText(f"Saving {os.path.basename(self.current_file_path)}...")
|
self.status_label.setText(f"Saving {os.path.basename(self.current_file_path)}...")
|
||||||
|
|
||||||
|
# Rebuild all_packets from pages to ensure edits/undos/new packets are included.
|
||||||
|
# This serializes the pages in order, effectively "cleaning" the stream of orphans
|
||||||
|
# and ensuring the file matches the editor state.
|
||||||
|
new_all_packets = []
|
||||||
|
for page in self.service.pages:
|
||||||
|
new_all_packets.extend(page.packets)
|
||||||
|
self.service.all_packets = new_all_packets
|
||||||
|
|
||||||
save_t42(self.current_file_path, self.service, progress_callback=self.update_progress)
|
save_t42(self.current_file_path, self.service, progress_callback=self.update_progress)
|
||||||
|
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
self.status_label.setText(f"Saved {len(self.service.pages)} pages to {os.path.basename(self.current_file_path)}")
|
self.status_label.setText(f"Saved {len(self.service.pages)} pages to {os.path.basename(self.current_file_path)}")
|
||||||
self.set_modified(False)
|
self.set_modified(False)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
QMessageBox.critical(self, "Error", f"Failed to save file: {e}")
|
QMessageBox.critical(self, "Error", f"Failed to save file: {e}")
|
||||||
self.status_label.setText("Error saving file")
|
self.status_label.setText("Error saving file")
|
||||||
|
return False
|
||||||
|
|
||||||
def copy_page_content(self):
|
def copy_page_content(self):
|
||||||
if not self.current_page:
|
if not self.current_page:
|
||||||
@@ -475,7 +496,8 @@ class MainWindow(QMainWindow):
|
|||||||
sorted_keys = sorted(self.page_groups.keys())
|
sorted_keys = sorted(self.page_groups.keys())
|
||||||
|
|
||||||
for mag, pnum in sorted_keys:
|
for mag, pnum in sorted_keys:
|
||||||
label = f"{mag}{pnum:02d}"
|
# Display as Hex
|
||||||
|
label = f"{mag}{pnum:02X}"
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, (mag, pnum))
|
item.setData(Qt.ItemDataRole.UserRole, (mag, pnum))
|
||||||
self.page_list.addItem(item)
|
self.page_list.addItem(item)
|
||||||
@@ -507,6 +529,7 @@ class MainWindow(QMainWindow):
|
|||||||
if isinstance(page, Page):
|
if isinstance(page, Page):
|
||||||
self.current_page = page
|
self.current_page = page
|
||||||
self.canvas.set_page(page)
|
self.canvas.set_page(page)
|
||||||
|
self.update_language_label()
|
||||||
self.canvas.setFocus()
|
self.canvas.setFocus()
|
||||||
|
|
||||||
def insert_char(self, char_code):
|
def insert_char(self, char_code):
|
||||||
@@ -550,6 +573,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.canvas.move_cursor(-1, 0)
|
self.canvas.move_cursor(-1, 0)
|
||||||
elif key == Qt.Key.Key_Right:
|
elif key == Qt.Key.Key_Right:
|
||||||
self.canvas.move_cursor(1, 0)
|
self.canvas.move_cursor(1, 0)
|
||||||
|
elif key == Qt.Key.Key_Home:
|
||||||
|
# Move to start of line
|
||||||
|
self.canvas.set_cursor(0, self.canvas.cursor_y)
|
||||||
|
elif key == Qt.Key.Key_End:
|
||||||
|
# Move to end of line (39)
|
||||||
|
self.canvas.set_cursor(39, self.canvas.cursor_y)
|
||||||
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
||||||
# Move to start of next line
|
# Move to start of next line
|
||||||
self.canvas.cursor_x = 0
|
self.canvas.cursor_x = 0
|
||||||
|
|||||||
BIN
test_out.t42
BIN
test_out.t42
Binary file not shown.
Reference in New Issue
Block a user