diff --git a/src/qteletextmaker/hashformats.cpp b/src/qteletextmaker/hashformats.cpp new file mode 100644 index 0000000..64b6cf4 --- /dev/null +++ b/src/qteletextmaker/hashformats.cpp @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2020-2025 Gavin MacGregor + * + * This file is part of QTeletextMaker. + * + * QTeletextMaker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QTeletextMaker is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QTeletextMaker. If not, see . + */ + +#include "hashformats.h" + +#include +#include + +#include "levelonepage.h" +#include "pagebase.h" + +QString exportHashStringPage(LevelOnePage *subPage) +{ + int hashDigits[1167]={0}; + int totalBits, charBit; + const char base64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + QString hashString; + QByteArray rowPacket; + + // TODO int editTFCharacterSet = 5; + bool blackForeground = false; + + for (int r=0; r<25; r++) { + if (subPage->packetExists(r)) + rowPacket = subPage->packet(r); + else + rowPacket = QByteArray(40, 0x20).constData(); + + for (int c=0; c<40; c++) { + if (rowPacket.at(c) == 0x00 || rowPacket.at(c) == 0x10) + blackForeground = true; + for (int b=0; b<7; b++) { + totalBits = (r * 40 + c) * 7 + b; + charBit = ((rowPacket.at(c)) >> (6 - b)) & 0x01; + hashDigits[totalBits / 6] |= charBit << (5 - (totalBits % 6)); + } + } + } + + hashString.append(QString("#%1:").arg(blackForeground ? 8 : 0, 1, 16)); + + for (int i=0; i<1167; i++) + hashString.append(base64[hashDigits[i]]); + + return hashString; +} + +QString exportHashStringPackets(LevelOnePage *subPage) +{ + auto colourToHexString=[&](int whichCLUT) + { + QString resultHexString; + + for (int i=whichCLUT*8; iCLUT(i), 3, 16, QChar('0'))); + return resultHexString; + }; + + const char base64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + QString result; + + if (subPage->packetExists(28,0) || subPage->packetExists(28,4)) { + // X/28/0 and X/28/4 are duplicates apart from the CLUT definitions + // Assemble the duplicate beginning and ending of both packets + QString x28StringBegin, x28StringEnd; + + x28StringBegin.append(QString("00%1").arg((subPage->defaultCharSet() << 3) | subPage->defaultNOS(), 2, 16, QChar('0')).toUpper()); + x28StringBegin.append(QString("%1").arg((subPage->secondCharSet() << 3) | subPage->secondNOS(), 2, 16, QChar('0')).toUpper()); + x28StringBegin.append(QString("%1%2%3%4").arg(subPage->leftSidePanelDisplayed(), 1, 10).arg(subPage->rightSidePanelDisplayed(), 1, 10).arg(subPage->sidePanelStatusL25(), 1, 10).arg(subPage->sidePanelColumns(), 1, 16)); + + x28StringEnd = QString("%1%2%3%4").arg(subPage->defaultScreenColour(), 2, 16, QChar('0')).arg(subPage->defaultRowColour(), 2, 16, QChar('0')).arg(subPage->blackBackgroundSubst(), 1, 10).arg(subPage->colourTableRemap(), 1, 10); + + if (subPage->packetExists(28,0)) + result.append(":X280=" + x28StringBegin + colourToHexString(2) + colourToHexString(3) + x28StringEnd); + if (subPage->packetExists(28,4)) + result.append(":X284=" + x28StringBegin + colourToHexString(0) + colourToHexString(1) + x28StringEnd); + } + + if (!subPage->enhancements()->isEmpty()) { + result.append(":X26="); + for (int i=0; ienhancements()->size(); i++) { + result.append(base64[subPage->enhancements()->at(i).data() >> 1]); + result.append(base64[subPage->enhancements()->at(i).mode() | ((subPage->enhancements()->at(i).data() & 1) << 5)]); + result.append(base64[subPage->enhancements()->at(i).address()]); + } + } + + // Assemble PS + // C4 Erase page is stored in bit 14 + int pageStatus = 0x8000 | (subPage->controlBit(PageBase::C4ErasePage) << 14); + // C5 to C11 stored in order from bits 1 to 6 + for (int i=PageBase::C5Newsflash; i<=PageBase::C11SerialMagazine; i++) + pageStatus |= subPage->controlBit(i) << (i-1); + // Apparently the TTI format stores the NOS bits backwards + pageStatus |= subPage->controlBit(PageBase::C12NOS) << 9; + pageStatus |= subPage->controlBit(PageBase::C13NOS) << 8; + pageStatus |= subPage->controlBit(PageBase::C14NOS) << 7; + + result.append(QString(":PS=%1").arg(0x8000 | pageStatus, 0, 16, QChar('0'))); + return result; +} diff --git a/src/qteletextmaker/loadsave.h b/src/qteletextmaker/hashformats.h similarity index 63% rename from src/qteletextmaker/loadsave.h rename to src/qteletextmaker/hashformats.h index 49545bb..d2f56b8 100644 --- a/src/qteletextmaker/loadsave.h +++ b/src/qteletextmaker/hashformats.h @@ -17,30 +17,15 @@ * along with QTeletextMaker. If not, see . */ -#ifndef LOADSAVE_H -#define LOADSAVE_H +#ifndef HASHFORMATS_H +#define HASHFORMATS_H #include -#include -#include #include -#include -#include "document.h" #include "levelonepage.h" #include "pagebase.h" -void loadTTI(QFile *inFile, TeletextDocument *document); -void importT42(QFile *inFile, TeletextDocument *document); - -int controlBitsToPS(PageBase *subPage); - -void saveTTI(QSaveFile &file, const TeletextDocument &document); -void exportT42File(QSaveFile &file, const TeletextDocument &document); -void exportM29File(QSaveFile &file, const TeletextDocument &document); - -QByteArray rowPacketAlways(PageBase *subPage, int packetNumber); - QString exportHashStringPage(LevelOnePage *subPage); QString exportHashStringPackets(LevelOnePage *subPage); diff --git a/src/qteletextmaker/loadformats.cpp b/src/qteletextmaker/loadformats.cpp new file mode 100644 index 0000000..627af48 --- /dev/null +++ b/src/qteletextmaker/loadformats.cpp @@ -0,0 +1,571 @@ +/* + * Copyright (C) 2020-2025 Gavin MacGregor + * + * This file is part of QTeletextMaker. + * + * QTeletextMaker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QTeletextMaker is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QTeletextMaker. If not, see . + */ + +#include "loadformats.h" + +#include +#include +#include +#include +#include +#include + +#include "document.h" +#include "hamming.h" +#include "levelonepage.h" +#include "pagebase.h" + +bool LoadTTIFormat::load(QFile *inFile, TeletextDocument *document) +{ + m_warnings.clear(); + m_error.clear(); + + QByteArray inLine; + bool firstSubPageAlreadyFound = false; + bool pageBodyPacketsFound = false; + int cycleCommandsFound = 0; + int mostRecentCycleValue = -1; + LevelOnePage::CycleTypeEnum mostRecentCycleType; + + LevelOnePage* loadingPage = document->subPage(0); + + for (;;) { + inLine = inFile->readLine(160).trimmed(); + if (inLine.isEmpty()) + break; + if (inLine.startsWith("DE,")) + document->setDescription(QString(inLine.remove(0, 3))); + if (inLine.startsWith("PN,")) { + // When second and subsequent PN commands are found, firstSubPageAlreadyFound==true at this point + // This assumes that PN is the first command of a new subpage... + if (firstSubPageAlreadyFound) { + document->insertSubPage(document->numberOfSubPages(), false); + loadingPage = document->subPage(document->numberOfSubPages()-1); + } else { + document->setPageNumberFromString(inLine.mid(3,3)); + firstSubPageAlreadyFound = true; + } + } +/* if (lineType == "SC,") { + bool subPageNumberOk; + int subPageNumberRead = inLine.mid(3, 4).toInt(&subPageNumberOk, 16); + if ((!subPageNumberOk) || subPageNumberRead > 0x3f7f) + subPageNumberRead = 0; + loadingPage->setSubPageNumber(subPageNumberRead); + }*/ + if (inLine.startsWith("PS,")) { + bool pageStatusOk; + int pageStatusRead = inLine.mid(3, 4).toInt(&pageStatusOk, 16); + if (pageStatusOk) { + loadingPage->setControlBit(PageBase::C4ErasePage, pageStatusRead & 0x4000); + for (int i=PageBase::C5Newsflash, pageStatusBit=0x0001; i<=PageBase::C11SerialMagazine; i++, pageStatusBit<<=1) + loadingPage->setControlBit(i, pageStatusRead & pageStatusBit); + loadingPage->setDefaultNOS(((pageStatusRead & 0x0200) >> 9) | ((pageStatusRead & 0x0100) >> 7) | ((pageStatusRead & 0x0080) >> 5)); + } + } + if (inLine.startsWith("CT,") && (inLine.endsWith(",C") || inLine.endsWith(",T"))) { + bool cycleValueOk; + int cycleValueRead = inLine.mid(3, inLine.size()-5).toInt(&cycleValueOk); + if (cycleValueOk) { + cycleCommandsFound++; + // House-keep CT command values, in case it's the only one within multiple subpages + mostRecentCycleValue = cycleValueRead; + loadingPage->setCycleValue(cycleValueRead); + mostRecentCycleType = inLine.endsWith("C") ? LevelOnePage::CTcycles : LevelOnePage::CTseconds; + loadingPage->setCycleType(mostRecentCycleType); + } + } + if (inLine.startsWith("FL,")) { + bool fastTextLinkOk; + int fastTextLinkRead; + QString flLine = QString(inLine.remove(0, 3)); + if (flLine.count(',') == 5) + for (int i=0; i<6; i++) { + fastTextLinkRead = flLine.section(',', i, i).toInt(&fastTextLinkOk, 16); + if (fastTextLinkOk) { + if (fastTextLinkRead == 0) + fastTextLinkRead = 0x8ff; + // Stored as page link with relative magazine number, convert from absolute page number that was read + fastTextLinkRead ^= document->pageNumber() & 0x700; + fastTextLinkRead &= 0x7ff; // Fixes magazine 8 to 0 + loadingPage->setFastTextLinkPageNumber(i, fastTextLinkRead); + } + } + } + if (inLine.startsWith("OL,")) { + bool lineNumberOk; + int lineNumber, secondCommaPosition; + + secondCommaPosition = inLine.indexOf(",", 3); + if (secondCommaPosition != 4 && secondCommaPosition != 5) + continue; + + lineNumber = inLine.mid(3, secondCommaPosition-3).toInt(&lineNumberOk, 10); + if (lineNumberOk && lineNumber >= 0 && lineNumber <= 29) { + pageBodyPacketsFound = true; + + inLine.remove(0, secondCommaPosition+1); + if (lineNumber <= 25) { + for (int c=0; c<40; c++) { + // trimmed() helpfully removes CRLF line endings from the just-read line for us + // But it also (un)helpfully removes spaces at the end of a 40 character line, so put them back + if (c >= inLine.size()) + inLine.append(' '); + if (inLine.at(c) & 0x80) + inLine[c] = inLine.at(c) & 0x7f; + else if (inLine.at(c) == 0x10) + inLine[c] = 0x0d; + else if (inLine.at(c) == 0x1b) { + inLine.remove(c, 1); + inLine[c] = inLine.at(c) & 0xbf; + } + } + loadingPage->setPacket(lineNumber, inLine); + } else { + int designationCode = inLine.at(0) & 0x3f; + if (inLine.size() < 40) { + // OL is too short! + if (lineNumber == 26) { + // For a too-short enhancement triplets OL, first trim the line down to nearest whole triplet + inLine.resize((inLine.size() / 3 * 3) + 1); + // Then use "dummy" enhancement triplets to extend the line to the proper length + for (int i=inLine.size(); i<40; i+=3) + inLine.append("i^@"); // Address 41, Mode 0x1e, Data 0 + } else + // For other triplet OLs and Hamming 8/4 OLs, just pad with zero data + for (int i=inLine.size(); i<40; i++) + inLine.append("@"); + } + for (int i=1; i<=39; i++) + inLine[i] = inLine.at(i) & 0x3f; + // Import M/29 whole-magazine packets as X/28 per-page packets + if (lineNumber == 29) { + if ((document->pageNumber() & 0xff) != 0xff) + m_warnings.append(QString("M/29/%1 packet found, but page number was not xFF.").arg(designationCode)); + lineNumber = 28; + } + loadingPage->setPacket(lineNumber, designationCode, inLine); + } + } + } + } + + if (!pageBodyPacketsFound) { + m_error = "No OL lines found"; + return false; + } + + // If there's more than one subpage but only one valid CT command was found, apply it to all subpages + // I don't know if this is correct + if (cycleCommandsFound == 1 && document->numberOfSubPages()>1) + for (int i=0; inumberOfSubPages(); i++) { + document->subPage(i)->setCycleValue(mostRecentCycleValue); + document->subPage(i)->setCycleType(mostRecentCycleType); + } + + return true; +} + + +bool LoadT42Format::readPacket() +{ + return m_inFile->read((char *)m_inLine, 42) == 42; +} + +bool LoadT42Format::load(QFile *inFile, TeletextDocument *document) +{ + int readMagazineNumber, readPacketNumber; + int foundMagazineNumber = -1; + int foundPageNumber = -1; + bool firstPacket0Found = false; + bool pageBodyPacketsFound = false; + + m_inFile = inFile; + + m_warnings.clear(); + m_error.clear(); + + for (;;) { + if (!readPacket()) + // Reached end of .t42 file, or less than 42 bytes left + break; + + // Magazine and packet numbers + m_inLine[0] = hamming_8_4_decode[m_inLine[0]]; + m_inLine[1] = hamming_8_4_decode[m_inLine[1]]; + if (m_inLine[0] == 0xff || m_inLine[1] == 0xff) + // Error decoding magazine or packet number + continue; + readMagazineNumber = m_inLine[0] & 0x07; + readPacketNumber = (m_inLine[0] >> 3) | (m_inLine[1] << 1); + + if (readPacketNumber == 0) { + // Hamming decode page number, subcodes and control bits + for (int i=2; i<10; i++) + m_inLine[i] = hamming_8_4_decode[m_inLine[i]]; + // See if the page number is valid + if (m_inLine[2] == 0xff || m_inLine[3] == 0xff) + // Error decoding page number + continue; + + const int readPageNumber = (m_inLine[3] << 4) | m_inLine[2]; + + if (readPageNumber == 0xff) + // Time filling header + continue; + + // A second or subsequent X/0 has been found + if (firstPacket0Found) { + if (readMagazineNumber != foundMagazineNumber) + // Packet from different magazine broadcast in parallel mode + continue; + if ((readPageNumber == foundPageNumber) && pageBodyPacketsFound) + // X/0 with same page number found after page body packets loaded - assume end of page + break; + if (readPageNumber != foundPageNumber) { + // More than one page in .t42 file - end of current page reached + m_warnings.append("More than one page in .t42 file, only first full page loaded."); + break; + } + // Could get here if X/0 with same page number was found with no body packets inbetween + continue; + } else { + // First X/0 found + foundMagazineNumber = readMagazineNumber; + foundPageNumber = readPageNumber; + firstPacket0Found = true; + + if (foundMagazineNumber == 0) + document->setPageNumber(0x800 | foundPageNumber); + else + document->setPageNumber((foundMagazineNumber << 8) | foundPageNumber); + + document->subPage(0)->setControlBit(PageBase::C4ErasePage, m_inLine[5] & 0x08); + document->subPage(0)->setControlBit(PageBase::C5Newsflash, m_inLine[7] & 0x04); + document->subPage(0)->setControlBit(PageBase::C6Subtitle, m_inLine[7] & 0x08); + for (int i=0; i<4; i++) + document->subPage(0)->setControlBit(PageBase::C7SuppressHeader+i, m_inLine[8] & (1 << i)); + document->subPage(0)->setControlBit(PageBase::C11SerialMagazine, m_inLine[9] & 0x01); + document->subPage(0)->setControlBit(PageBase::C12NOS, m_inLine[9] & 0x08); + document->subPage(0)->setControlBit(PageBase::C13NOS, m_inLine[9] & 0x04); + document->subPage(0)->setControlBit(PageBase::C14NOS, m_inLine[9] & 0x02); + + continue; + } + } + + // No X/0 has been found yet, keep looking for one + if (!firstPacket0Found) + continue; + + // Disregard whole-magazine packets + if (readPacketNumber > 28) + continue; + + // We get here when a page-body packet belonging to the found X/0 header was found + pageBodyPacketsFound = true; + + // At the moment this only loads a Level One Page properly + // because it assumes X/1 to X/25 is odd partity + if (readPacketNumber < 25) { + for (int i=2; i<42; i++) + // TODO - obey odd parity? + m_inLine[i] &= 0x7f; + document->subPage(0)->setPacket(readPacketNumber, QByteArray((const char *)&m_inLine[2], 40)); + continue; + } + + // X/26, X/27 or X/28 + int readDesignationCode = hamming_8_4_decode[m_inLine[2]]; + + if (readDesignationCode == 0xff) + // Error decoding designation code + continue; + + if (readPacketNumber == 27 && readDesignationCode < 4) { + // X/27/0 to X/27/3 for Editorial Linking + // Decode Hamming 8/4 on each of the six links, checking for errors on the way + for (int i=0; i<6; i++) { + bool decodingError = false; + const int b = 3 + i*6; // First byte of this link + + for (int j=0; j<6; j++) { + m_inLine[b+j] = hamming_8_4_decode[m_inLine[b+j]]; + if (m_inLine[b+j] == 0xff) { + decodingError = true; + break; + } + } + + if (decodingError) { + // Error found in at least one byte of the link + // Neutralise the whole link to same magazine, page FF, subcode 3F7F + qDebug("X/27/%d link %d decoding error", readDesignationCode, i); + m_inLine[b] = 0xf; + m_inLine[b+1] = 0xf; + m_inLine[b+2] = 0xf; + m_inLine[b+3] = 0x7; + m_inLine[b+4] = 0xf; + m_inLine[b+5] = 0x3; + } + } + document->subPage(0)->setPacket(readPacketNumber, readDesignationCode, QByteArray((const char *)&m_inLine[2], 40)); + + continue; + } + + // X/26, or X/27/4 to X/27/15, or X/28 + // Decode Hamming 24/18 + for (int i=0; i<13; i++) { + const int b = 3 + i*3; // First byte of triplet + + const int p0 = m_inLine[b]; + const int p1 = m_inLine[b+1]; + const int p2 = m_inLine[b+2]; + + unsigned int D1_D4; + unsigned int D5_D11; + unsigned int D12_D18; + unsigned int ABCDEF; + int32_t d; + + D1_D4 = hamming_24_18_decode_d1_d4[p0 >> 2]; + D5_D11 = p1 & 0x7f; + D12_D18 = p2 & 0x7f; + + d = D1_D4 | (D5_D11 << 4) | (D12_D18 << 11); + + ABCDEF = (hamming_24_18_parities[0][p0] ^ hamming_24_18_parities[1][p1] ^ hamming_24_18_parities[2][p2]); + + d ^= (int)hamming_24_18_decode_correct[ABCDEF]; + + if ((d & 0x80000000) == 0x80000000) { + // Error decoding Hamming 24/18 + qDebug("X/%d/%d triplet %d decoding error", readPacketNumber, readDesignationCode, i); + if (readPacketNumber == 26) { + // Enhancements packet, set to "dummy" Address 41, Mode 0x1e, Data 0 + m_inLine[b] = 41; + m_inLine[b+1] = 0x1e; + m_inLine[b+2] = 0; + } else { + // Zero out whole decoded triplet, bound to make things go wrong... + m_inLine[b] = 0x00; + m_inLine[b+1] = 0x00; + m_inLine[b+2] = 0x00; + } + } else { + m_inLine[b] = d & 0x0003f; + m_inLine[b+1] = (d & 0x00fc0) >> 6; + m_inLine[b+2] = d >> 12; + } + } + document->subPage(0)->setPacket(readPacketNumber, readDesignationCode, QByteArray((const char *)&m_inLine[2], 40)); + } + + if (!firstPacket0Found) { + m_error = "No X/0 found."; + return false; + } else if (!pageBodyPacketsFound) { + m_error = "X/0 found, but no page body packets were found."; + return false; + } else + return true; +} + + +bool LoadHTTFormat::readPacket() +{ + unsigned char httLine[45]; + + if (m_inFile->read((char *)httLine, 45) != 45) + return false; + + if (httLine[0] != 0xaa || httLine[1] != 0xaa || httLine[2] != 0xe4) + return false; + + for (int i=0; i<42; i++) { + unsigned char b = httLine[i+3]; + b = (b & 0xf0) >> 4 | (b & 0x0f) << 4; + b = (b & 0xcc) >> 2 | (b & 0x33) << 2; + b = (b & 0xaa) >> 1 | (b & 0x55) << 1; + m_inLine[i] = b; + } + + return true; +} + + +bool LoadEP1Format::load(QFile *inFile, TeletextDocument *document) +{ + m_warnings.clear(); + m_error.clear(); + + unsigned char inLine[42]; + unsigned char numOfSubPages = 1; + + LevelOnePage* loadingPage = document->subPage(0); + + for (;;) { + // Read six bytes, will either be a header for a (sub)page + // or a start header indicating multiple subpages are within + if (inFile->read((char *)inLine, 6) != 6) + return false; + if (inLine[0] == 'J' || inLine[1] == 'W' || inLine[2] == 'C') { + // Multiple subpages: get number of subpages then read + // next six bytes that really will be the header of the first subpage + numOfSubPages = inLine[3]; + if (inFile->read((char *)inLine, 6) != 6) + return false; + + m_warnings.append("More than one page in EP1/EPX file, only first full page loaded."); + } + + // Check for header of a (sub)page + if (inLine[0] != 0xfe || inLine[1] != 0x01) + return false; + + // Deal with language code unique to EP1 - unknown values are mapped to English + loadingPage->setDefaultCharSet(m_languageCode.key(inLine[2], 0x09) >> 3); + loadingPage->setDefaultNOS(m_languageCode.key(inLine[2], 0x09) & 0x7); + + // If fourth byte is 0xca then "X/26 enhancements header" follows + // Otherwise Level 1 page data follows + if (inLine[3] == 0xca) { + // Read next four bytes that form the "X/26 enhancements header" + if (inFile->read((char *)inLine, 4) != 4) + return false; + // Third and fourth bytes are little-endian length of enhancement data + const int numOfX26Bytes = inLine[2] | (inLine[3] << 8); + const int numOfX26Packets = (numOfX26Bytes + 39) / 40; + + QByteArray packet(40, 0x00); + packet[0] = 0; + + for (int i=0; iread((char *)inLine, 40) != 40) + return false; + + // Assumes that X/26 packets are saved with ascending designation codes... + for (int c=1; c<39; c+=3) { + if (!terminatorFound) { + // Shuffle triplet bits from 6 bit address, 5 bit mode, 7 bit data + packet[c] = inLine[c]; + packet[c+1] = inLine[c+1] | ((inLine[c+2] & 1) << 5); + packet[c+2] = inLine[c+2] >> 1; + // Address of termination marker is 7f instead of 3f + if (inLine[c+1] == 0x1f && inLine[c] == 0x7f) { + packet[c] = 0x3f; + + if (inLine[c+2] & 0x01) { + // If a termination marker was found, stop reading the packet + // and repeat the marker ourselves to the end + terminatorFound = true; + terminatorTriplet[0] = packet[c+1]; + terminatorTriplet[1] = packet[c+2]; + } + } + } else { + packet[c] = 0x3f; + packet[c+1] = terminatorTriplet[0]; + packet[c+2] = terminatorTriplet[1]; + } + } + + loadingPage->setPacket(26, i, packet); + } + } + + // Level 1 rows + for (int r=0; r<24; r++) { + if (inFile->read((char *)inLine, 40) != 40) + return false; + + for (int c=0; c<40; c++) + if (inLine[c] != 0x20) { + loadingPage->setPacket(r, QByteArray((const char *)&inLine, 40)); + break; + } + } + + numOfSubPages--; + + // FIXME uncomment "if" statement when we're ready to save multi-page EPX files + //if (numOfSubPages == 0) + break; + + // There are more subpages coming up so skip over the 40 byte buffer and 2 byte terminator + if (inFile->read((char *)inLine, 42) != 42) + return false; + + document->insertSubPage(document->numberOfSubPages(), false); + loadingPage = document->subPage(document->numberOfSubPages()-1); + } + return true; +} + + +int LoadFormats::s_instances = 0; + +LoadFormats::LoadFormats() +{ + if (s_instances == 0) { + s_fileFormat[0] = new LoadTTIFormat; + s_fileFormat[1] = new LoadT42Format; + s_fileFormat[2] = new LoadEP1Format; + s_fileFormat[3] = new LoadHTTFormat; + + s_filters = "All Supported Files (*."; + + for (int i=0; iextensions().join(" *.")); + } + s_filters.append(");;"); + + for (int i=0; ifileDialogFilter()); + } + } + + s_instances++; +} + +LoadFormats::~LoadFormats() +{ + s_instances--; + + if (s_instances == 0) + for (int i=s_size-1; i>=0; i--) + delete s_fileFormat[i]; +} + +LoadFormat *LoadFormats::findFormat(const QString &suffix) const +{ + for (int i=0; iextensions().contains(suffix, Qt::CaseInsensitive)) + return s_fileFormat[i]; + + return nullptr; +} diff --git a/src/qteletextmaker/loadformats.h b/src/qteletextmaker/loadformats.h new file mode 100644 index 0000000..4ebe3e6 --- /dev/null +++ b/src/qteletextmaker/loadformats.h @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2020-2025 Gavin MacGregor + * + * This file is part of QTeletextMaker. + * + * QTeletextMaker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QTeletextMaker is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QTeletextMaker. If not, see . + */ + +#ifndef LOADFORMATS_H +#define LOADFORMATS_H + +#include +#include +#include +#include +#include +#include + +#include "document.h" +#include "levelonepage.h" +#include "pagebase.h" + +class LoadFormat +{ +public: + virtual ~LoadFormat() {}; + + virtual bool load(QFile *inFile, TeletextDocument *document) =0; + + virtual QString description() const =0; + virtual QStringList extensions() const =0; + QString fileDialogFilter() const { return QString(description() + " (*." + extensions().join(" *.") + ')'); }; + QStringList warningStrings() const { return m_warnings; }; + QString errorString() const { return m_error; }; + +protected: + TeletextDocument const *m_document; + QStringList m_warnings; + QString m_error; +}; + +class LoadTTIFormat : public LoadFormat +{ +public: + bool load(QFile *inFile, TeletextDocument *document) override; + + QString description() const override { return QString("MRG Systems TTI"); }; + QStringList extensions() const override { return QStringList { "tti", "ttix" }; }; +}; + +class LoadT42Format : public LoadFormat +{ +public: + bool load(QFile *inFile, TeletextDocument *document) override; + + QString description() const override { return QString("t42 packet stream"); }; + QStringList extensions() const override { return QStringList { "t42" }; }; + +protected: + virtual bool readPacket(); + + QFile *m_inFile; + unsigned char m_inLine[42]; +}; + +class LoadHTTFormat : public LoadT42Format +{ +public: + QString description() const override { return QString("HMS SD-Teletext HTT"); }; + QStringList extensions() const override { return QStringList { "htt" }; }; + +protected: + bool readPacket() override; +}; + +class LoadEP1Format : public LoadFormat +{ +public: + bool load(QFile *inFile, TeletextDocument *document) override; + + QString description() const override { return QString("Softel EP1"); }; + QStringList extensions() const override { return QStringList { "ep1", "epx" }; }; + +protected: + // Language codes unique to EP1 + // FIXME duplicated in saveformats.h + const QMap m_languageCode { + { 0x00, 0x09 }, { 0x01, 0x0d }, { 0x02, 0x18 }, { 0x03, 0x11 }, { 0x04, 0x0b }, { 0x05, 0x17 }, { 0x06, 0x07 }, + { 0x08, 0x14 }, { 0x09, 0x0d }, { 0x0a, 0x18 }, { 0x0b, 0x11 }, { 0x0c, 0x0b }, { 0x0e, 0x07 }, + { 0x10, 0x09 }, { 0x11, 0x0d }, { 0x12, 0x18 }, { 0x13, 0x11 }, { 0x14, 0x0b }, { 0x15, 0x17 }, { 0x16, 0x1c }, + { 0x1d, 0x1e }, { 0x1f, 0x16 }, + { 0x21, 0x0d }, { 0x22, 0xff }, { 0x23, 0xff }, { 0x26, 0x07 }, + { 0x36, 0x1c }, { 0x37, 0x0e }, + { 0x40, 0x09 }, { 0x44, 0x0b } + }; +}; + + +class LoadFormats +{ +public: + LoadFormats(); + ~LoadFormats(); + + LoadFormat *findFormat(const QString &suffix) const; + QString filters() const { return s_filters; }; + +private: + static const inline int s_size = 4; + static int s_instances; + inline static LoadFormat *s_fileFormat[s_size]; + inline static QString s_filters; +}; + +#endif diff --git a/src/qteletextmaker/loadsave.cpp b/src/qteletextmaker/loadsave.cpp deleted file mode 100644 index 58e708c..0000000 --- a/src/qteletextmaker/loadsave.cpp +++ /dev/null @@ -1,811 +0,0 @@ -/* - * Copyright (C) 2020-2025 Gavin MacGregor - * - * This file is part of QTeletextMaker. - * - * QTeletextMaker is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * QTeletextMaker is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with QTeletextMaker. If not, see . - */ - -#include "loadsave.h" - -#include -#include -#include -#include -#include -#include - -#include "document.h" -#include "hamming.h" -#include "levelonepage.h" -#include "pagebase.h" - -void loadTTI(QFile *inFile, TeletextDocument *document) -{ - QByteArray inLine; - bool firstSubPageAlreadyFound = false; - int cycleCommandsFound = 0; - int mostRecentCycleValue = -1; - LevelOnePage::CycleTypeEnum mostRecentCycleType; - - LevelOnePage* loadingPage = document->subPage(0); - - for (;;) { - inLine = inFile->readLine(160).trimmed(); - if (inLine.isEmpty()) - break; - if (inLine.startsWith("DE,")) - document->setDescription(QString(inLine.remove(0, 3))); - if (inLine.startsWith("PN,")) { - // When second and subsequent PN commands are found, firstSubPageAlreadyFound==true at this point - // This assumes that PN is the first command of a new subpage... - if (firstSubPageAlreadyFound) { - document->insertSubPage(document->numberOfSubPages(), false); - loadingPage = document->subPage(document->numberOfSubPages()-1); - } else { - document->setPageNumberFromString(inLine.mid(3,3)); - firstSubPageAlreadyFound = true; - } - } -/* if (lineType == "SC,") { - bool subPageNumberOk; - int subPageNumberRead = inLine.mid(3, 4).toInt(&subPageNumberOk, 16); - if ((!subPageNumberOk) || subPageNumberRead > 0x3f7f) - subPageNumberRead = 0; - loadingPage->setSubPageNumber(subPageNumberRead); - }*/ - if (inLine.startsWith("PS,")) { - bool pageStatusOk; - int pageStatusRead = inLine.mid(3, 4).toInt(&pageStatusOk, 16); - if (pageStatusOk) { - loadingPage->setControlBit(PageBase::C4ErasePage, pageStatusRead & 0x4000); - for (int i=PageBase::C5Newsflash, pageStatusBit=0x0001; i<=PageBase::C11SerialMagazine; i++, pageStatusBit<<=1) - loadingPage->setControlBit(i, pageStatusRead & pageStatusBit); - loadingPage->setDefaultNOS(((pageStatusRead & 0x0200) >> 9) | ((pageStatusRead & 0x0100) >> 7) | ((pageStatusRead & 0x0080) >> 5)); - } - } - if (inLine.startsWith("CT,") && (inLine.endsWith(",C") || inLine.endsWith(",T"))) { - bool cycleValueOk; - int cycleValueRead = inLine.mid(3, inLine.size()-5).toInt(&cycleValueOk); - if (cycleValueOk) { - cycleCommandsFound++; - // House-keep CT command values, in case it's the only one within multiple subpages - mostRecentCycleValue = cycleValueRead; - loadingPage->setCycleValue(cycleValueRead); - mostRecentCycleType = inLine.endsWith("C") ? LevelOnePage::CTcycles : LevelOnePage::CTseconds; - loadingPage->setCycleType(mostRecentCycleType); - } - } - if (inLine.startsWith("FL,")) { - bool fastTextLinkOk; - int fastTextLinkRead; - QString flLine = QString(inLine.remove(0, 3)); - if (flLine.count(',') == 5) - for (int i=0; i<6; i++) { - fastTextLinkRead = flLine.section(',', i, i).toInt(&fastTextLinkOk, 16); - if (fastTextLinkOk) { - if (fastTextLinkRead == 0) - fastTextLinkRead = 0x8ff; - // Stored as page link with relative magazine number, convert from absolute page number that was read - fastTextLinkRead ^= document->pageNumber() & 0x700; - fastTextLinkRead &= 0x7ff; // Fixes magazine 8 to 0 - loadingPage->setFastTextLinkPageNumber(i, fastTextLinkRead); - } - } - } - if (inLine.startsWith("OL,")) { - bool lineNumberOk; - int lineNumber, secondCommaPosition; - - secondCommaPosition = inLine.indexOf(",", 3); - if (secondCommaPosition != 4 && secondCommaPosition != 5) - continue; - - lineNumber = inLine.mid(3, secondCommaPosition-3).toInt(&lineNumberOk, 10); - if (lineNumberOk && lineNumber>=0 && lineNumber<=29) { - inLine.remove(0, secondCommaPosition+1); - if (lineNumber <= 25) { - for (int c=0; c<40; c++) { - // trimmed() helpfully removes CRLF line endings from the just-read line for us - // But it also (un)helpfully removes spaces at the end of a 40 character line, so put them back - if (c >= inLine.size()) - inLine.append(' '); - if (inLine.at(c) & 0x80) - inLine[c] = inLine.at(c) & 0x7f; - else if (inLine.at(c) == 0x10) - inLine[c] = 0x0d; - else if (inLine.at(c) == 0x1b) { - inLine.remove(c, 1); - inLine[c] = inLine.at(c) & 0xbf; - } - } - loadingPage->setPacket(lineNumber, inLine); - } else { - int designationCode = inLine.at(0) & 0x3f; - if (inLine.size() < 40) { - // OL is too short! - if (lineNumber == 26) { - // For a too-short enhancement triplets OL, first trim the line down to nearest whole triplet - inLine.resize((inLine.size() / 3 * 3) + 1); - // Then use "dummy" enhancement triplets to extend the line to the proper length - for (int i=inLine.size(); i<40; i+=3) - inLine.append("i^@"); // Address 41, Mode 0x1e, Data 0 - } else - // For other triplet OLs and Hamming 8/4 OLs, just pad with zero data - for (int i=inLine.size(); i<40; i++) - inLine.append("@"); - } - for (int i=1; i<=39; i++) - inLine[i] = inLine.at(i) & 0x3f; - // Import M/29 whole-magazine packets as X/28 per-page packets - if (lineNumber == 29) { - if ((document->pageNumber() & 0xff) != 0xff) - qDebug("M/29/%d packet found, but page number is not xFF!", designationCode); - lineNumber = 28; - } - loadingPage->setPacket(lineNumber, designationCode, inLine); - } - } - } - } - // If there's more than one subpage but only one valid CT command was found, apply it to all subpages - // I don't know if this is correct - if (cycleCommandsFound == 1 && document->numberOfSubPages()>1) - for (int i=0; inumberOfSubPages(); i++) { - document->subPage(i)->setCycleValue(mostRecentCycleValue); - document->subPage(i)->setCycleType(mostRecentCycleType); - } -} - -void importT42(QFile *inFile, TeletextDocument *document) -{ - unsigned char inLine[42]; - int readMagazineNumber, readPacketNumber; - int foundMagazineNumber = -1; - int foundPageNumber = -1; - bool firstPacket0Found = false; - bool pageBodyPacketsFound = false; - - for (;;) { - if (inFile->read((char *)inLine, 42) != 42) - // Reached end of .t42 file, or less than 42 bytes left - break; - - // Magazine and packet numbers - inLine[0] = hamming_8_4_decode[inLine[0]]; - inLine[1] = hamming_8_4_decode[inLine[1]]; - if (inLine[0] == 0xff || inLine[1] == 0xff) - // Error decoding magazine or packet number - continue; - readMagazineNumber = inLine[0] & 0x07; - readPacketNumber = (inLine[0] >> 3) | (inLine[1] << 1); - - if (readPacketNumber == 0) { - // Hamming decode page number, subcodes and control bits - for (int i=2; i<10; i++) - inLine[i] = hamming_8_4_decode[inLine[i]]; - // See if the page number is valid - if (inLine[2] == 0xff || inLine[3] == 0xff) - // Error decoding page number - continue; - - const int readPageNumber = (inLine[3] << 4) | inLine[2]; - - if (readPageNumber == 0xff) - // Time filling header - continue; - - // A second or subsequent X/0 has been found - if (firstPacket0Found) { - if (readMagazineNumber != foundMagazineNumber) - // Packet from different magazine broadcast in parallel mode - continue; - if ((readPageNumber == foundPageNumber) && pageBodyPacketsFound) - // X/0 with same page number found after page body packets loaded - assume end of page - break; - if (readPageNumber != foundPageNumber) { - // More than one page in .t42 file - end of current page reached - qDebug("More than one page in .t42 file"); - break; - } - // Could get here if X/0 with same page number was found with no body packets inbetween - continue; - } else { - // First X/0 found - foundMagazineNumber = readMagazineNumber; - foundPageNumber = readPageNumber; - firstPacket0Found = true; - - if (foundMagazineNumber == 0) - document->setPageNumber(0x800 | foundPageNumber); - else - document->setPageNumber((foundMagazineNumber << 8) | foundPageNumber); - - document->subPage(0)->setControlBit(PageBase::C4ErasePage, inLine[5] & 0x08); - document->subPage(0)->setControlBit(PageBase::C5Newsflash, inLine[7] & 0x04); - document->subPage(0)->setControlBit(PageBase::C6Subtitle, inLine[7] & 0x08); - for (int i=0; i<4; i++) - document->subPage(0)->setControlBit(PageBase::C7SuppressHeader+i, inLine[8] & (1 << i)); - document->subPage(0)->setControlBit(PageBase::C11SerialMagazine, inLine[9] & 0x01); - document->subPage(0)->setControlBit(PageBase::C12NOS, inLine[9] & 0x08); - document->subPage(0)->setControlBit(PageBase::C13NOS, inLine[9] & 0x04); - document->subPage(0)->setControlBit(PageBase::C14NOS, inLine[9] & 0x02); - - continue; - } - } - - // No X/0 has been found yet, keep looking for one - if (!firstPacket0Found) - continue; - - // Disregard whole-magazine packets - if (readPacketNumber > 28) - continue; - - // We get here when a page-body packet belonging to the found X/0 header was found - pageBodyPacketsFound = true; - - // At the moment this only loads a Level One Page properly - // because it assumes X/1 to X/25 is odd partity - if (readPacketNumber < 25) { - for (int i=2; i<42; i++) - // TODO - obey odd parity? - inLine[i] &= 0x7f; - document->subPage(0)->setPacket(readPacketNumber, QByteArray((const char *)&inLine[2], 40)); - continue; - } - - // X/26, X/27 or X/28 - int readDesignationCode = hamming_8_4_decode[inLine[2]]; - - if (readDesignationCode == 0xff) - // Error decoding designation code - continue; - - if (readPacketNumber == 27 && readDesignationCode < 4) { - // X/27/0 to X/27/3 for Editorial Linking - // Decode Hamming 8/4 on each of the six links, checking for errors on the way - for (int i=0; i<6; i++) { - bool decodingError = false; - const int b = 3 + i*6; // First byte of this link - - for (int j=0; j<6; j++) { - inLine[b+j] = hamming_8_4_decode[inLine[b+j]]; - if (inLine[b+j] == 0xff) { - decodingError = true; - break; - } - } - - if (decodingError) { - // Error found in at least one byte of the link - // Neutralise the whole link to same magazine, page FF, subcode 3F7F - qDebug("X/27/%d link %d decoding error", readDesignationCode, i); - inLine[b] = 0xf; - inLine[b+1] = 0xf; - inLine[b+2] = 0xf; - inLine[b+3] = 0x7; - inLine[b+4] = 0xf; - inLine[b+5] = 0x3; - } - } - document->subPage(0)->setPacket(readPacketNumber, readDesignationCode, QByteArray((const char *)&inLine[2], 40)); - - continue; - } - - // X/26, or X/27/4 to X/27/15, or X/28 - // Decode Hamming 24/18 - for (int i=0; i<13; i++) { - const int b = 3 + i*3; // First byte of triplet - - const int p0 = inLine[b]; - const int p1 = inLine[b+1]; - const int p2 = inLine[b+2]; - - unsigned int D1_D4; - unsigned int D5_D11; - unsigned int D12_D18; - unsigned int ABCDEF; - int32_t d; - - D1_D4 = hamming_24_18_decode_d1_d4[p0 >> 2]; - D5_D11 = p1 & 0x7f; - D12_D18 = p2 & 0x7f; - - d = D1_D4 | (D5_D11 << 4) | (D12_D18 << 11); - - ABCDEF = (hamming_24_18_parities[0][p0] ^ hamming_24_18_parities[1][p1] ^ hamming_24_18_parities[2][p2]); - - d ^= (int)hamming_24_18_decode_correct[ABCDEF]; - - if ((d & 0x80000000) == 0x80000000) { - // Error decoding Hamming 24/18 - qDebug("X/%d/%d triplet %d decoding error", readPacketNumber, readDesignationCode, i); - if (readPacketNumber == 26) { - // Enhancements packet, set to "dummy" Address 41, Mode 0x1e, Data 0 - inLine[b] = 41; - inLine[b+1] = 0x1e; - inLine[b+2] = 0; - } else { - // Zero out whole decoded triplet, bound to make things go wrong... - inLine[b] = 0x00; - inLine[b+1] = 0x00; - inLine[b+2] = 0x00; - } - } else { - inLine[b] = d & 0x0003f; - inLine[b+1] = (d & 0x00fc0) >> 6; - inLine[b+2] = d >> 12; - } - } - document->subPage(0)->setPacket(readPacketNumber, readDesignationCode, QByteArray((const char *)&inLine[2], 40)); - } - - if (!firstPacket0Found) - qDebug("No X/0 found"); - else if (!pageBodyPacketsFound) - qDebug("X/0 found, but no page body packets were found"); -} - -// Used by saveTTI and HashString -int controlBitsToPS(PageBase *subPage) -{ - // C4 Erase page is stored in bit 14 - int pageStatus = 0x8000 | (subPage->controlBit(PageBase::C4ErasePage) << 14); - // C5 to C11 stored in order from bits 1 to 6 - for (int i=PageBase::C5Newsflash; i<=PageBase::C11SerialMagazine; i++) - pageStatus |= subPage->controlBit(i) << (i-1); - // Apparently the TTI format stores the NOS bits backwards - pageStatus |= subPage->controlBit(PageBase::C12NOS) << 9; - pageStatus |= subPage->controlBit(PageBase::C13NOS) << 8; - pageStatus |= subPage->controlBit(PageBase::C14NOS) << 7; - return pageStatus; -} - -void saveTTI(QSaveFile &file, const TeletextDocument &document) -{ - int p; - QTextStream outStream(&file); - - auto write7bitPacket=[&](int packetNumber) - { - if (document.subPage(p)->packetExists(packetNumber)) { - QByteArray outLine = document.subPage(p)->packet(packetNumber); - - outStream << QString("OL,%1,").arg(packetNumber); - for (int c=0; c= QT_VERSION_CHECK(5, 14, 0) - outStream << outLine << Qt::endl; -#else - outStream << outLine << endl; -#endif - } - }; - - auto writeHammingPacket=[&](int packetNumber, int designationCode=0) - { - if (document.subPage(p)->packetExists(packetNumber, designationCode)) { - QByteArray outLine = document.subPage(p)->packet(packetNumber, designationCode); - - outStream << QString("OL,%1,").arg(packetNumber); - // TTI stores raw values with bit 6 set, doesn't do Hamming encoding - outLine[0] = designationCode | 0x40; - for (int c=1; c= QT_VERSION_CHECK(5, 14, 0) - outStream << outLine << Qt::endl; -#else - outStream << outLine << endl; -#endif - } - }; - -#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) - outStream.setCodec("ISO-8859-1"); -#else - outStream.setEncoding(QStringConverter::Latin1); -#endif - - if (!document.description().isEmpty()) -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << "DE," << document.description() << Qt::endl; -#else - outStream << "DE," << document.description() << endl; -#endif - - // TODO DS and SP commands - - // If there's just one subpage then we save it with a subcode of 0000 - // otherwise start with a subcode of 0001 - int subPageNumber = document.numberOfSubPages() > 1; - - for (p=0; p= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - - // Subpage - // Magazine Organisation Table and Magazine Inventory Page don't have subpages - if (document.pageFunction() != TeletextDocument::PFMOT && document.pageFunction() != TeletextDocument::PFMIP) { - outStream << QString("SC,%1").arg(subPageNumber, 4, 10, QChar('0')); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - } - - // Status bits - outStream << QString("PS,%1").arg(0x8000 | controlBitsToPS(document.subPage(p)), 4, 16, QChar('0')); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - - // Cycle time - if (document.pageFunction() == TeletextDocument::PFLevelOnePage) - // Assume that only Level One Pages have configurable cycle times - outStream << QString("CT,%1,%2").arg(document.subPage(p)->cycleValue()).arg(document.subPage(p)->cycleType()==LevelOnePage::CTcycles ? 'C' : 'T'); - else - // X/28/0 specifies page function and coding but the PF command - // should make it obvious to a human that this isn't a Level One Page - outStream << QString("PF,%1,%2").arg(document.pageFunction()).arg(document.packetCoding()); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - - // FastText links - bool writeFLCommand = false; - if (document.pageFunction() == TeletextDocument::PFLevelOnePage && document.subPage(p)->packetExists(27,0)) { - // Subpage has FastText links - if any link to a specific subpage, we need to write X/27/0 as raw - // otherwise we write the links as a human-readable FL command later on - writeFLCommand = true; - // TODO uncomment this when we can edit FastText subpage links - /*for (int i=0; i<6; i++) - if (document.subPage(p)->fastTextLinkSubPageNumber(i) != 0x3f7f) { - writeFLCommand = false; - break; - }*/ - } - - // X/27 then X/28 always come first - for (int i=(writeFLCommand ? 1 : 0); i<16; i++) - writeHammingPacket(27, i); - for (int i=0; i<16; i++) - writeHammingPacket(28, i); - - if (document.packetCoding() == TeletextDocument::Coding7bit) { - // For 7 bit coding i.e. Level One Pages, X/26 are written before X/1 to X/25 - for (int i=0; i<16; i++) - writeHammingPacket(26, i); - for (int i=1; i<=24; i++) - write7bitPacket(i); - } else { - // For others (especially (G)POP pages) X/1 to X/25 are written before X/26 - for (int i=1; i<=25; i++) - writeHammingPacket(i); - for (int i=0; i<16; i++) - writeHammingPacket(26, i); - } - - if (writeFLCommand) { - outStream << "FL,"; - for (int i=0; i<6; i++) { - // Stored as page link with relative magazine number, convert to absolute page number for display - int absoluteLinkPageNumber = document.subPage(p)->fastTextLinkPageNumber(i) ^ (document.pageNumber() & 0x700); - // Fix magazine 0 to 8 - if ((absoluteLinkPageNumber & 0x700) == 0x000) - absoluteLinkPageNumber |= 0x800; - - outStream << QString("%1").arg(absoluteLinkPageNumber, 3, 16, QChar('0')); - if (i<5) - outStream << ','; - } -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - } - - subPageNumber++; - } -} - -void exportM29File(QSaveFile &file, const TeletextDocument &document) -{ - const PageBase &subPage = *document.currentSubPage(); - QTextStream outStream(&file); - - auto writeM29Packet=[&](int designationCode) - { - if (subPage.packetExists(28, designationCode)) { - QByteArray outLine = subPage.packet(28, designationCode); - - outStream << QString("OL,29,"); - // TTI stores raw values with bit 6 set, doesn't do Hamming encoding - outLine[0] = designationCode | 0x40; - for (int c=1; c= QT_VERSION_CHECK(5, 14, 0) - outStream << outLine << Qt::endl; -#else - outStream << outLine << endl; -#endif - } - }; - -#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) - outStream.setCodec("ISO-8859-1"); -#else - outStream.setEncoding(QStringConverter::Latin1); -#endif - - if (!document.description().isEmpty()) -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << "DE," << document.description() << Qt::endl; -#else - outStream << "DE," << document.description() << endl; -#endif - - // Force page number to xFF - outStream << QString("PN,%1ff00").arg(document.pageNumber() >> 8, 1, 16); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - - outStream << "PS,8000"; -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - outStream << Qt::endl; -#else - outStream << endl; -#endif - - writeM29Packet(0); - writeM29Packet(1); - writeM29Packet(4); -} - -void exportT42File(QSaveFile &file, const TeletextDocument &document) -{ - const PageBase &subPage = *document.currentSubPage(); - - QDataStream outStream(&file); - // Displayable row header we export as spaces, hence the (odd parity valid) 0x20 init value - QByteArray outLine(42, 0x20); - int magazineNumber = (document.pageNumber() & 0xf00) >> 8; - - auto write7bitPacket=[&](int packetNumber) - { - if (subPage.packetExists(packetNumber)) { - outLine[0] = hamming_8_4_encode[magazineNumber | ((packetNumber & 0x01) << 3)]; - outLine[1] = hamming_8_4_encode[packetNumber >> 1]; - outLine.replace(2, 40, subPage.packet(packetNumber)); - - // Odd parity encoding - for (int c=0; c> 4; - p ^= p >> 2; - p ^= p >> 1; - // If last bit left is 0 then it started with an even number of bits, so do the odd parity - if (!(p & 1)) - outLine[c] = outLine.at(c) | 0x80; - } - outStream.writeRawData(outLine.constData(), 42); - } - }; - - auto writeHamming8_4Packet=[&](int packetNumber, int designationCode=0) - { - if (subPage.packetExists(packetNumber, designationCode)) { - outLine[0] = hamming_8_4_encode[magazineNumber | ((packetNumber & 0x01) << 3)]; - outLine[1] = hamming_8_4_encode[packetNumber >> 1]; - outLine.replace(2, 40, subPage.packet(packetNumber, designationCode)); - outLine[2] = hamming_8_4_encode[designationCode]; - - for (int c=3; c> 1]; - outLine.replace(2, 40, subPage.packet(packetNumber, designationCode)); - outLine[2] = hamming_8_4_encode[designationCode]; - - for (int c=3; c> 0) & 0xff] ^ hamming_24_18_forward[1][(toEncode >> 8) & 0xff] ^ hamming_24_18_forward_2[(toEncode >> 16) & 0x03]); - outLine[c] = Byte_0; - - D5_D11 = (toEncode >> 4) & 0x7f; - D12_D18 = (toEncode >> 11) & 0x7f; - - P5 = 0x80 & ~(hamming_24_18_parities[0][D12_D18] << 2); - outLine[c+1] = D5_D11 | P5; - - P6 = 0x80 & ((hamming_24_18_parities[0][Byte_0] ^ hamming_24_18_parities[0][D5_D11]) << 2); - outLine[c+2] = D12_D18 | P6; - } - - outStream.writeRawData(outLine.constData(), 42); - } - }; - - - if (magazineNumber == 8) - magazineNumber = 0; - - // Write X/0 separately as it features both Hamming 8/4 and 7-bit odd parity within - outLine[0] = magazineNumber & 0x07; - outLine[1] = 0; // Packet number 0 - outLine[2] = document.pageNumber() & 0x00f; - outLine[3] = (document.pageNumber() & 0x0f0) >> 4; - outLine[4] = 0; // Subcode S1 - always export as 0 - outLine[5] = subPage.controlBit(PageBase::C4ErasePage) << 3; - outLine[6] = 0; // Subcode S3 - always export as 0 - outLine[7] = (subPage.controlBit(PageBase::C5Newsflash) << 2) | (subPage.controlBit(PageBase::C6Subtitle) << 3); - outLine[8] = subPage.controlBit(PageBase::C7SuppressHeader) | (subPage.controlBit(PageBase::C8Update) << 1) | (subPage.controlBit(PageBase::C9InterruptedSequence) << 2) | (subPage.controlBit(PageBase::C10InhibitDisplay) << 3); - outLine[9] = subPage.controlBit(PageBase::C11SerialMagazine) | (subPage.controlBit(PageBase::C14NOS) << 1) | (subPage.controlBit(PageBase::C13NOS) << 2) | (subPage.controlBit(PageBase::C12NOS) << 3); - - for (int i=0; i<10; i++) - outLine[i] = hamming_8_4_encode[(int)outLine.at(i)]; - - // If we allow text in the row header, we'd odd-parity encode it here - - outStream.writeRawData(outLine.constData(), 42); - - // After X/0, X/27 then X/28 always come next - for (int i=0; i<4; i++) - writeHamming8_4Packet(27, i); - for (int i=4; i<16; i++) - writeHamming24_18Packet(27, i); - for (int i=0; i<16; i++) - writeHamming24_18Packet(28, i); - - if (document.packetCoding() == TeletextDocument::Coding7bit) { - // For 7 bit coding i.e. Level One Pages, X/26 are written before X/1 to X/25 - for (int i=0; i<16; i++) - writeHamming24_18Packet(26, i); - for (int i=1; i<=24; i++) - write7bitPacket(i); - } else { - // For others (especially (G)POP pages) X/1 to X/25 are written before X/26 - if (document.packetCoding() == TeletextDocument::Coding18bit) - for (int i=1; i<=25; i++) - writeHamming24_18Packet(i); - else if (document.packetCoding() == TeletextDocument::Coding4bit) - for (int i=1; i<=25; i++) - writeHamming8_4Packet(i); - else - qDebug("Exported broken file as page coding is not supported"); - for (int i=0; i<16; i++) - writeHamming24_18Packet(26, i); - } -} - -QByteArray rowPacketAlways(PageBase *subPage, int packetNumber) -{ - if (subPage->packetExists(packetNumber)) - return subPage->packet(packetNumber); - else - return QByteArray(40, ' '); -} - -QString exportHashStringPage(LevelOnePage *subPage) -{ - int hashDigits[1167]={0}; - int totalBits, charBit; - const char base64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - QString hashString; - - // TODO int editTFCharacterSet = 5; - bool blackForeground = false; - - for (int r=0; r<25; r++) { - QByteArray rowPacket = rowPacketAlways(subPage, r); - for (int c=0; c<40; c++) { - if (rowPacket.at(c) == 0x00 || rowPacket.at(c) == 0x10) - blackForeground = true; - for (int b=0; b<7; b++) { - totalBits = (r * 40 + c) * 7 + b; - charBit = ((rowPacket.at(c)) >> (6 - b)) & 0x01; - hashDigits[totalBits / 6] |= charBit << (5 - (totalBits % 6)); - } - } - } - - hashString.append(QString("#%1:").arg(blackForeground ? 8 : 0, 1, 16)); - - for (int i=0; i<1167; i++) - hashString.append(base64[hashDigits[i]]); - - return hashString; -} - -QString exportHashStringPackets(LevelOnePage *subPage) -{ - auto colourToHexString=[&](int whichCLUT) - { - QString resultHexString; - - for (int i=whichCLUT*8; iCLUT(i), 3, 16, QChar('0'))); - return resultHexString; - }; - - const char base64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - QString result; - - if (subPage->packetExists(28,0) || subPage->packetExists(28,4)) { - // X/28/0 and X/28/4 are duplicates apart from the CLUT definitions - // Assemble the duplicate beginning and ending of both packets - QString x28StringBegin, x28StringEnd; - - x28StringBegin.append(QString("00%1").arg((subPage->defaultCharSet() << 3) | subPage->defaultNOS(), 2, 16, QChar('0')).toUpper()); - x28StringBegin.append(QString("%1").arg((subPage->secondCharSet() << 3) | subPage->secondNOS(), 2, 16, QChar('0')).toUpper()); - x28StringBegin.append(QString("%1%2%3%4").arg(subPage->leftSidePanelDisplayed(), 1, 10).arg(subPage->rightSidePanelDisplayed(), 1, 10).arg(subPage->sidePanelStatusL25(), 1, 10).arg(subPage->sidePanelColumns(), 1, 16)); - - x28StringEnd = QString("%1%2%3%4").arg(subPage->defaultScreenColour(), 2, 16, QChar('0')).arg(subPage->defaultRowColour(), 2, 16, QChar('0')).arg(subPage->blackBackgroundSubst(), 1, 10).arg(subPage->colourTableRemap(), 1, 10); - - if (subPage->packetExists(28,0)) - result.append(":X280=" + x28StringBegin + colourToHexString(2) + colourToHexString(3) + x28StringEnd); - if (subPage->packetExists(28,4)) - result.append(":X284=" + x28StringBegin + colourToHexString(0) + colourToHexString(1) + x28StringEnd); - } - - if (!subPage->enhancements()->isEmpty()) { - result.append(":X26="); - for (int i=0; ienhancements()->size(); i++) { - result.append(base64[subPage->enhancements()->at(i).data() >> 1]); - result.append(base64[subPage->enhancements()->at(i).mode() | ((subPage->enhancements()->at(i).data() & 1) << 5)]); - result.append(base64[subPage->enhancements()->at(i).address()]); - } - } - - result.append(QString(":PS=%1").arg(0x8000 | controlBitsToPS(subPage), 0, 16, QChar('0'))); - return result; -} diff --git a/src/qteletextmaker/mainwindow.cpp b/src/qteletextmaker/mainwindow.cpp index ea131b0..7084433 100644 --- a/src/qteletextmaker/mainwindow.cpp +++ b/src/qteletextmaker/mainwindow.cpp @@ -41,13 +41,15 @@ #include "mainwindow.h" +#include "hashformats.h" #include "levelonecommands.h" -#include "loadsave.h" +#include "loadformats.h" #include "mainwidget.h" #include "pagecomposelinksdockwidget.h" #include "pageenhancementsdockwidget.h" #include "pageoptionsdockwidget.h" #include "palettedockwidget.h" +#include "saveformats.h" #include "x26dockwidget.h" #include "gifimage/qgifimage.h" @@ -84,7 +86,7 @@ void MainWindow::newFile() void MainWindow::open() { - const QString fileName = QFileDialog::getOpenFileName(this); + const QString fileName = QFileDialog::getOpenFileName(this, tr("Open File"), QString(), m_loadFormats.filters()); if (!fileName.isEmpty()) openFile(fileName); } @@ -114,39 +116,37 @@ void MainWindow::openFile(const QString &fileName) other->show(); } -static inline bool hasTTISuffix(const QString &filename) -{ - return filename.endsWith(".tti", Qt::CaseInsensitive) || filename.endsWith(".ttix", Qt::CaseInsensitive); -} - -static inline void changeSuffixFromTTI(QString &filename, const QString &newSuffix) -{ - if (filename.endsWith(".tti", Qt::CaseInsensitive)) { - filename.chop(4); - filename.append("." + newSuffix); - } else if (filename.endsWith(".ttix", Qt::CaseInsensitive)) { - filename.chop(5); - filename.append("." + newSuffix); - } -} - bool MainWindow::save() { - // If imported from non-.tti, force "Save As" so we don't clobber the original imported file - return m_isUntitled || !hasTTISuffix(m_curFile) ? saveAs() : saveFile(m_curFile); + // If imported from a format we only export, force "Save As" so we don't clobber the original imported file + if (m_isUntitled || m_saveFormats.isExportOnly(QFileInfo(m_curFile).suffix())) + return saveAs(); + else + return saveFile(m_curFile); } bool MainWindow::saveAs() { QString suggestedName = m_curFile; - // If imported from non-.tti, change extension so we don't clobber the original imported file - if (suggestedName.endsWith(".t42", Qt::CaseInsensitive)) { - suggestedName.chop(4); + // If imported from a format we only export, change suffix so we don't clobber the original imported file + if (m_saveFormats.isExportOnly(QFileInfo(suggestedName).suffix())) { + const int pos = suggestedName.lastIndexOf(QChar('.')); + if (pos != -1) + suggestedName.truncate(pos); + suggestedName.append(".tti"); } - QString fileName = QFileDialog::getSaveFileName(this, tr("Save As"), suggestedName, "TTI teletext page (*.tti *.ttix)"); + // Set the filter in the file dialog to the same as the current filename extension + QString dialogFilter; + + SaveFormat *savingFormat = m_saveFormats.findExportFormat(QFileInfo(suggestedName).suffix()); + + if (savingFormat != nullptr) + dialogFilter = savingFormat->fileDialogFilter(); + + QString fileName = QFileDialog::getSaveFileName(this, tr("Save As"), suggestedName, m_saveFormats.filters(), &dialogFilter); if (fileName.isEmpty()) return false; @@ -461,9 +461,9 @@ void MainWindow::createActions() connect(fileMenu, &QMenu::aboutToShow, this, &MainWindow::updateExportAutoAction); connect(m_exportAutoAct, &QAction::triggered, this, &MainWindow::exportAuto); - QAction *exportT42Act = fileMenu->addAction(tr("Export subpage as t42...")); - exportT42Act->setStatusTip("Export this subpage as a t42 file"); - connect(exportT42Act, &QAction::triggered, this, [=]() { exportT42(false); }); + QAction *exportFileAct = fileMenu->addAction(tr("Export subpage as...")); + exportFileAct->setStatusTip("Export this subpage to various formats"); + connect(exportFileAct, &QAction::triggered, this, [=]() { exportFile(false); }); QMenu *exportHashStringSubMenu = fileMenu->addMenu(tr("Export subpage to online editor")); @@ -1043,27 +1043,36 @@ void MainWindow::loadFile(const QString &fileName) QFile file(fileName); const QFileInfo fileInfo(file); - QIODevice::OpenMode fileOpenMode; - if (fileInfo.suffix() == "t42") - fileOpenMode = QFile::ReadOnly; - else - fileOpenMode = QFile::ReadOnly | QFile::Text; + LoadFormat *loadingFormat = m_loadFormats.findFormat(QFileInfo(fileName).suffix()); + if (loadingFormat == nullptr) { + QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("Cannot load file %1:\nUnknown file format or extension").arg(QDir::toNativeSeparators(fileName))); + setCurrentFile(QString()); - if (!file.open(fileOpenMode)) { + return; + } + + if (!file.open(QFile::ReadOnly)) { QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("Cannot read file %1:\n%2.").arg(QDir::toNativeSeparators(fileName), file.errorString())); setCurrentFile(QString()); + return; } QApplication::setOverrideCursor(Qt::WaitCursor); - if (fileInfo.suffix() == "t42") { - importT42(&file, m_textWidget->document()); - m_exportAutoFileName = fileName; + if (loadingFormat->load(&file, m_textWidget->document())) { + // TODO put "native format" into class? + if (fileInfo.suffix() == "tti" || fileInfo.suffix() == "ttix") + m_exportAutoFileName.clear(); + else + m_exportAutoFileName = fileName; } else { - loadTTI(&file, m_textWidget->document()); - m_exportAutoFileName.clear(); + QApplication::restoreOverrideCursor(); + QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("Cannot load file %1\n%2").arg(QDir::toNativeSeparators(fileName), loadingFormat->errorString())); + setCurrentFile(QString()); + + return; } levelSeen = m_textWidget->document()->levelRequired(); @@ -1075,6 +1084,9 @@ void MainWindow::loadFile(const QString &fileName) QApplication::restoreOverrideCursor(); + if (!loadingFormat->warningStrings().isEmpty()) + QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("The following issues were encountered when loading
%1:
  • %2
").arg(QDir::toNativeSeparators(fileName), loadingFormat->warningStrings().join("
  • "))); + setCurrentFile(fileName); statusBar()->showMessage(tr("File loaded"), 2000); } @@ -1172,14 +1184,23 @@ bool MainWindow::saveFile(const QString &fileName) { QString errorMessage; + SaveFormat *savingFormat = m_saveFormats.findFormat(QFileInfo(fileName).suffix()); + if (savingFormat == nullptr) { + QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("Cannot save file %1:\nUnknown file format or extension").arg(QDir::toNativeSeparators(fileName))); + return false; + } + QApplication::setOverrideCursor(Qt::WaitCursor); + QSaveFile file(fileName); - if (file.open(QFile::WriteOnly | QFile::Text)) { - saveTTI(file, *m_textWidget->document()); + if (file.open(QFile::WriteOnly)) { + savingFormat->saveAllPages(file, *m_textWidget->document()); + if (!file.commit()) errorMessage = tr("Cannot write file %1:\n%2.").arg(QDir::toNativeSeparators(fileName), file.errorString()); } else errorMessage = tr("Cannot open file %1 for writing:\n%2.").arg(QDir::toNativeSeparators(fileName), file.errorString()); + QApplication::restoreOverrideCursor(); if (!errorMessage.isEmpty()) { @@ -1198,33 +1219,61 @@ void MainWindow::exportAuto() if (m_exportAutoFileName.isEmpty()) return; - exportT42(true); + exportFile(true); } -void MainWindow::exportT42(bool fromAuto) +void MainWindow::exportFile(bool fromAuto) { QString errorMessage; QString exportFileName; + SaveFormat *exportFormat = nullptr; if (fromAuto) exportFileName = m_exportAutoFileName; else { - exportFileName = m_curFile; - changeSuffixFromTTI(exportFileName, "t42"); + if (m_exportAutoFileName.isEmpty()) + exportFileName = m_curFile; + else + exportFileName = m_exportAutoFileName; - exportFileName = QFileDialog::getSaveFileName(this, tr("Export t42"), exportFileName, "t42 stream (*.t42)"); + // Set the filter in the file dialog to the same as the current filename extension + QString dialogFilter; + + exportFormat = m_saveFormats.findExportFormat(QFileInfo(exportFileName).suffix()); + + if (exportFormat != nullptr) + dialogFilter = exportFormat->fileDialogFilter(); + + exportFileName = QFileDialog::getSaveFileName(this, tr("Export subpage"), exportFileName, m_saveFormats.exportFilters(), &dialogFilter); if (exportFileName.isEmpty()) return; } + exportFormat = m_saveFormats.findExportFormat(QFileInfo(exportFileName).suffix()); + if (exportFormat == nullptr) { + QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("Cannot export file %1:\nUnknown file format or extension").arg(QDir::toNativeSeparators(exportFileName))); + return; + } + + if (!fromAuto && exportFormat->getWarnings(*m_textWidget->document()->currentSubPage())) { + const QMessageBox::StandardButton ret = QMessageBox::warning(this, QApplication::applicationDisplayName(), tr("The following issues will be encountered when exporting
    %1:
    • %2
    Do you want to export?").arg(strippedName(exportFileName)).arg(exportFormat->warningStrings().join("
  • ")), QMessageBox::Yes | QMessageBox::No); + + if (ret != QMessageBox::Yes) + return; + } + QApplication::setOverrideCursor(Qt::WaitCursor); + QSaveFile file(exportFileName); + if (file.open(QFile::WriteOnly)) { - exportT42File(file, *m_textWidget->document()); + exportFormat->saveCurrentSubPage(file, *m_textWidget->document()); + if (!file.commit()) errorMessage = tr("Cannot write file %1:\n%2.").arg(QDir::toNativeSeparators(exportFileName), file.errorString()); } else - errorMessage = tr("Cannot open file %1 for writing:\n%2.").arg(QDir::toNativeSeparators(exportFileName), file.errorString()); + errorMessage = tr("Cannot open file %1 for writing:\n%2.").arg(QDir::toNativeSeparators(exportFileName), file.errorString()); + QApplication::restoreOverrideCursor(); if (!errorMessage.isEmpty()) { @@ -1271,18 +1320,23 @@ void MainWindow::exportM29() exportFileName = QDir(QFileInfo(m_curFile).absoluteDir()).filePath(exportFileName); } - exportFileName = QFileDialog::getSaveFileName(this, tr("Export M/29 tti"), exportFileName, "TTI teletext page (*.tti *.ttix)"); + exportFileName = QFileDialog::getSaveFileName(this, tr("Export M/29 tti"), exportFileName, "MRG Systems TTI (*.tti *.ttix)"); if (exportFileName.isEmpty()) return; QApplication::setOverrideCursor(Qt::WaitCursor); + QSaveFile file(exportFileName); - if (file.open(QFile::WriteOnly | QFile::Text)) { - exportM29File(file, *m_textWidget->document()); + if (file.open(QFile::WriteOnly)) { + SaveM29Format saveM29Format; + + saveM29Format.saveCurrentSubPage(file, *m_textWidget->document()); + if (!file.commit()) errorMessage = tr("Cannot write file %1:\n%2.").arg(QDir::toNativeSeparators(exportFileName), file.errorString()); } else errorMessage = tr("Cannot open file %1 for writing:\n%2.").arg(QDir::toNativeSeparators(exportFileName), file.errorString()); + QApplication::restoreOverrideCursor(); if (!errorMessage.isEmpty()) diff --git a/src/qteletextmaker/mainwindow.h b/src/qteletextmaker/mainwindow.h index ca8bab7..44c65e2 100644 --- a/src/qteletextmaker/mainwindow.h +++ b/src/qteletextmaker/mainwindow.h @@ -30,11 +30,13 @@ #include #include +#include "loadformats.h" #include "mainwidget.h" #include "pagecomposelinksdockwidget.h" #include "pageenhancementsdockwidget.h" #include "pageoptionsdockwidget.h" #include "palettedockwidget.h" +#include "saveformats.h" #include "x26dockwidget.h" class QAction; @@ -61,7 +63,7 @@ private slots: bool saveAs(); void reload(); void exportAuto(); - void exportT42(bool fromAuto); + void exportFile(bool fromAuto); void exportZXNet(); void exportEditTF(); void exportImage(); @@ -138,6 +140,9 @@ private: QString m_curFile, m_exportAutoFileName, m_exportImageFileName; bool m_isUntitled; + + LoadFormats m_loadFormats; + SaveFormats m_saveFormats; }; #endif diff --git a/src/qteletextmaker/saveformats.cpp b/src/qteletextmaker/saveformats.cpp new file mode 100644 index 0000000..b4f6f9b --- /dev/null +++ b/src/qteletextmaker/saveformats.cpp @@ -0,0 +1,574 @@ +/* + * Copyright (C) 2020-2025 Gavin MacGregor + * + * This file is part of QTeletextMaker. + * + * QTeletextMaker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QTeletextMaker is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QTeletextMaker. If not, see . + */ + +#include "saveformats.h" + +#include +#include +#include +#include + +#include "document.h" +#include "hamming.h" +#include "levelonepage.h" +#include "pagebase.h" + +void SaveFormat::saveAllPages(QSaveFile &outFile, const TeletextDocument &document) +{ + m_document = &document; + m_outFile = &outFile; + m_outStream.setDevice(m_outFile); + m_warnings.clear(); + m_error.clear(); + + writeDocumentStart(); + writeAllPages(); + writeDocumentEnd(); +} + +void SaveFormat::saveCurrentSubPage(QSaveFile &outFile, const TeletextDocument &document) +{ + m_document = &document; + m_outFile = &outFile; + m_outStream.setDevice(m_outFile); + m_warnings.clear(); + m_error.clear(); + + writeDocumentStart(); + writeSubPage(*m_document->currentSubPage()); + writeDocumentEnd(); +} + +void SaveFormat::writeAllPages() +{ + for (int p=0; pnumberOfSubPages(); p++) + writeSubPage(*m_document->subPage(p), (m_document->numberOfSubPages() == 1) ? 0 : p+1); +} + +void SaveFormat::writeSubPage(const PageBase &subPage, int subPageNumber) +{ + writeSubPageStart(subPage, subPageNumber); + writeSubPageBody(subPage); + writeSubPageEnd(subPage); +} + +void SaveFormat::writeSubPageBody(const PageBase &subPage) +{ + writeX27Packets(subPage); + writeX28Packets(subPage); + if (m_document->pageFunction() == TeletextDocument::PFLevelOnePage) { + writeX26Packets(subPage); + writeX1to25Packets(subPage); + } else { + qDebug("Not LevelOnePage, assuming 7-bit packets!"); + writeX1to25Packets(subPage); + writeX26Packets(subPage); + } +} + +void SaveFormat::writeX27Packets(const PageBase &subPage) +{ + for (int i=0; i<4; i++) + if (subPage.packetExists(27, i)) + writePacket(format4BitPacket(subPage.packet(27, i)), 27, i); + for (int i=4; i<16; i++) + if (subPage.packetExists(27, i)) + writePacket(format18BitPacket(subPage.packet(27, i)), 27, i); +} + +void SaveFormat::writeX28Packets(const PageBase &subPage) +{ + for (int i=0; i<16; i++) + if (subPage.packetExists(28, i)) + writePacket(format18BitPacket(subPage.packet(28, i)), 28, i); +} + +void SaveFormat::writeX26Packets(const PageBase &subPage) +{ + for (int i=0; i<16; i++) + if (subPage.packetExists(26, i)) + writePacket(format18BitPacket(subPage.packet(26, i)), 26, i); +} + +void SaveFormat::writeX1to25Packets(const PageBase &subPage) +{ + for (int i=1; i<26; i++) + if (subPage.packetExists(i)) + // BUG must check m_document->packetCoding() !! + writePacket(format7BitPacket(subPage.packet(i)), i); +} + +int SaveFormat::writePacket(QByteArray packet, int packetNumber, int designationCode) +{ + return writeRawData(packet, packet.size()); +} + +int SaveFormat::writeRawData(const char *s, int len) +{ + return m_outStream.writeRawData(s, len); +} + + +inline void SaveTTIFormat::writeString(const QString &command) +{ + QByteArray result = command.toUtf8() + "\r\n"; + + writeRawData(result, result.size()); +} + +void SaveTTIFormat::writeDocumentStart() +{ + if (!m_document->description().isEmpty()) + writeString(QString("DE,%1").arg(m_document->description())); + + // TODO DS and SP commands +} + +QByteArray SaveTTIFormat::format7BitPacket(QByteArray packet) +{ + for (int i=0; ipageNumber(), 3, 16, QChar('0')).arg(subPageNumber & 0xff, 2, 10, QChar('0'))); + + // Subpage + // Magazine Organisation Table and Magazine Inventory Page don't have subpages + if (m_document->pageFunction() != TeletextDocument::PFMOT && m_document->pageFunction() != TeletextDocument::PFMIP) + writeString(QString("SC,%1").arg(subPageNumber, 4, 10, QChar('0'))); + + // Status bits + // We assume that bit 15 "transmit page" is always wanted + // C4 stored in bit 14 + int statusBits = 0x8000 | (subPage.controlBit(PageBase::C4ErasePage) << 14); + // C5 to C11 stored in order from bits 1 to 6 + for (int i=PageBase::C5Newsflash; i<=PageBase::C11SerialMagazine; i++) + statusBits |= subPage.controlBit(i) << (i-1); + // NOS in bits 7 to 9 + statusBits |= subPage.controlBit(PageBase::C12NOS) << 9; + statusBits |= subPage.controlBit(PageBase::C13NOS) << 8; + statusBits |= subPage.controlBit(PageBase::C14NOS) << 7; + + writeString(QString("PS,%1").arg(0x8000 | statusBits, 4, 16, QChar('0'))); + + if (m_document->pageFunction() == TeletextDocument::PFLevelOnePage) + writeString(QString("CT,%1,%2").arg(static_cast(&subPage)->cycleValue()).arg(static_cast(&subPage)->cycleType()==LevelOnePage::CTcycles ? 'C' : 'T')); + else + // X/28/0 specifies page function and coding but the PF command + // should make it obvious to a human that this is not a Level One Page + writeString(QString("PF,%1,%2").arg(m_document->pageFunction()).arg(m_document->packetCoding())); +} + +void SaveTTIFormat::writeSubPageBody(const PageBase &subPage) +{ + // FLOF links + bool writeFLCommand = false; + if (m_document->pageFunction() == TeletextDocument::PFLevelOnePage && subPage.packetExists(27,0)) { + // Subpage has FLOF links - if any link to a specific subpage we need to write X/27/0 + // as raw, otherwise we write the links as a human-readable FL command later on + writeFLCommand = true; + // TODO uncomment this when we can edit FLOF subpage links + /*for (int i=0; i<6; i++) + if (document.subPage(p)->fastTextLinkSubPageNumber(i) != 0x3f7f) { + writeFLCommand = false; + break; + }*/ + } + + // Don't write X/27/0 if FL command will be written + // but write the rest of the X/27 packets + for (int i=(writeFLCommand ? 1 : 0); i<4; i++) + if (subPage.packetExists(27, i)) + writePacket(format4BitPacket(subPage.packet(27, i)), 27, i); + for (int i=4; i<16; i++) + if (subPage.packetExists(27, i)) + writePacket(format18BitPacket(subPage.packet(27, i)), 27, i); + + // Now write the other packets like the rest of writeSubPageBody does + writeX28Packets(subPage); + if (m_document->pageFunction() == TeletextDocument::PFLevelOnePage) { + writeX26Packets(subPage); + writeX1to25Packets(subPage); + } else { + qDebug("Not LevelOnePage, assuming 7-bit packets!"); + writeX1to25Packets(subPage); + writeX26Packets(subPage); + } + + if (writeFLCommand) { + QString flofLine; + flofLine.reserve(26); + flofLine="FL,"; + + for (int i=0; i<6; i++) { + // Stored as page link with relative magazine number, convert to absolute page number for display + int absoluteLinkPageNumber = static_cast(&subPage)->fastTextLinkPageNumber(i) ^ (m_document->pageNumber() & 0x700); + // Fix magazine 0 to 8 + if ((absoluteLinkPageNumber & 0x700) == 0x000) + absoluteLinkPageNumber |= 0x800; + + flofLine.append(QString("%1").arg(absoluteLinkPageNumber, 3, 16, QChar('0'))); + if (i < 5) + flofLine.append(','); + } + + writeString(flofLine); + } +} + +int SaveTTIFormat::writePacket(QByteArray packet, int packetNumber, int designationCode) +{ + if (designationCode != -1) + packet[0] = designationCode | 0x40; + + packet.prepend("OL," + QByteArray::number(packetNumber) + ","); + packet.append("\r\n"); + + return(writeRawData(packet, packet.size())); +} + + +void SaveM29Format::writeSubPageStart(const PageBase &subPage, int subPageNumber) +{ + Q_UNUSED(subPageNumber); + + // Force page number to 0xFF and subpage to 0 + writeString(QString("PN,%1ff00").arg(m_document->pageNumber() >> 8, 1, 16)); + // Not sure if this PS forcing is necessary + writeString("PS,8000"); +} + +void SaveM29Format::writeSubPageBody(const PageBase &subPage) +{ + if (subPage.packetExists(28, 0)) + writePacket(format18BitPacket(subPage.packet(28, 0)), 29, 0); + if (subPage.packetExists(28, 1)) + writePacket(format18BitPacket(subPage.packet(28, 1)), 29, 1); + if (subPage.packetExists(28, 4)) + writePacket(format18BitPacket(subPage.packet(28, 4)), 29, 4); +} + + +QByteArray SaveT42Format::format7BitPacket(QByteArray packet) +{ + // Odd parity encoding + for (int c=0; c> 4; + p ^= p >> 2; + p ^= p >> 1; + // If last bit left is 0 then it started with an even number of bits, so do the odd parity + if (!(p & 1)) + packet[c] = packet.at(c) | 0x80; + } + + return packet; +} + +QByteArray SaveT42Format::format4BitPacket(QByteArray packet) +{ + for (int c=0; c> 0) & 0xff] ^ hamming_24_18_forward[1][(toEncode >> 8) & 0xff] ^ hamming_24_18_forward_2[(toEncode >> 16) & 0x03]); + packet[c] = Byte_0; + + D5_D11 = (toEncode >> 4) & 0x7f; + D12_D18 = (toEncode >> 11) & 0x7f; + + P5 = 0x80 & ~(hamming_24_18_parities[0][D12_D18] << 2); + packet[c+1] = D5_D11 | P5; + + P6 = 0x80 & ((hamming_24_18_parities[0][Byte_0] ^ hamming_24_18_parities[0][D5_D11]) << 2); + packet[c+2] = D12_D18 | P6; + } + + return packet; +} + +int SaveT42Format::writePacket(QByteArray packet, int packetNumber, int designationCode) +{ + // Byte 2 - designation code + if (designationCode != - 1) + packet[0] = hamming_8_4_encode[designationCode]; + + // Byte 1 of MRAG + packet.prepend(hamming_8_4_encode[packetNumber >> 1]); + // Byte 0 of MRAG + packet.prepend(hamming_8_4_encode[m_magazineNumber | ((packetNumber & 0x01) << 3)]); + + return(writeRawData(packet, packet.size())); +} + +void SaveT42Format::writeSubPageStart(const PageBase &subPage, int subPageNumber) +{ + // Convert integer to Binary Coded Decimal + subPageNumber = QString::number(subPageNumber).toInt(nullptr, 16); + + // Displayable row header we export as spaces, hence the (odd parity valid) 0x20 init value + QByteArray packet(42, 0x20); + + m_magazineNumber = (m_document->pageNumber() & 0xf00) >> 8; + if (m_magazineNumber == 8) + m_magazineNumber = 0; + + // Write X/0 separately as it features both Hamming 8/4 and 7-bit odd parity within + packet[0] = m_magazineNumber & 0x07; + packet[1] = 0; // Packet number 0 + packet[2] = m_document->pageNumber() & 0x00f; + packet[3] = (m_document->pageNumber() & 0x0f0) >> 4; + packet[4] = subPageNumber & 0xf; + packet[5] = ((subPageNumber >> 4) & 0x7) | (subPage.controlBit(PageBase::C4ErasePage) << 3); + packet[6] = ((subPageNumber >> 8) & 0xf); + packet[7] = ((subPageNumber >> 12) & 0x3) | (subPage.controlBit(PageBase::C5Newsflash) << 2) | (subPage.controlBit(PageBase::C6Subtitle) << 3); + packet[8] = subPage.controlBit(PageBase::C7SuppressHeader) | (subPage.controlBit(PageBase::C8Update) << 1) | (subPage.controlBit(PageBase::C9InterruptedSequence) << 2) | (subPage.controlBit(PageBase::C10InhibitDisplay) << 3); + packet[9] = subPage.controlBit(PageBase::C11SerialMagazine) | (subPage.controlBit(PageBase::C14NOS) << 1) | (subPage.controlBit(PageBase::C13NOS) << 2) | (subPage.controlBit(PageBase::C12NOS) << 3); + + for (int i=0; i<10; i++) + packet[i] = hamming_8_4_encode[(int)packet.at(i)]; + + writeRawData(packet.constData(), 42); +} + + +int SaveHTTFormat::writeRawData(const char *s, int len) +{ + char httLine[45]; + + httLine[0] = 0xaa; + httLine[1] = 0xaa; + httLine[2] = 0xe4; + + for (int i=0; i<42; i++) { + unsigned char b = s[i]; + b = (b & 0xf0) >> 4 | (b & 0x0f) << 4; + b = (b & 0xcc) >> 2 | (b & 0x33) << 2; + b = (b & 0xaa) >> 1 | (b & 0x55) << 1; + httLine[i+3] = b; + } + + return m_outStream.writeRawData(httLine, len+3); +} + + +bool SaveEP1Format::getWarnings(const PageBase &subPage) +{ + m_warnings.clear(); + + if (!m_languageCode.contains((static_cast(&subPage)->defaultCharSet() << 3) | static_cast(&subPage)->defaultNOS())) + m_warnings.append("Page language not supported, will be exported as English."); + + if (subPage.packetExists(24) || subPage.packetExists(27, 0)) + m_warnings.append("FLOF display row and page links will not be exported."); + + if (subPage.packetExists(27, 4) || subPage.packetExists(27, 5)) + m_warnings.append("X/27/4-5 compositional links will not be exported."); + + if (subPage.packetExists(28, 0) || subPage.packetExists(28, 4)) + m_warnings.append("X/28 page enhancements will not be exported."); + + return (!m_warnings.isEmpty()); +} + +QByteArray SaveEP1Format::format18BitPacket(QByteArray packet) +{ + for (int c=1; c> 5); + packet[c+1] = packet.at(c+1) & 0x1f; + // Address of termination marker is 7f instead of 3f + if (packet.at(c+1) == 0x1f && packet.at(c) == 0x3f) + packet[c] = 0x7f; + } + + return packet; +} + +void SaveEP1Format::writeSubPageStart(const PageBase &subPage, int subPageNumber) +{ + Q_UNUSED(subPageNumber); + + QByteArray pageStart(3, 0x00); + + // First two bytes always 0xfe, 0x01 + pageStart[0] = 0xfe; + pageStart[1] = 0x01; + // Next byte is language code unique to EP1 + // Unknown values are mapped to English, after warning the user + pageStart[2] = m_languageCode.value((static_cast(&subPage)->defaultCharSet() << 3) | static_cast(&subPage)->defaultNOS(), 0x09); + + writeRawData(pageStart.constData(), 3); +} + +void SaveEP1Format::writeSubPageBody(const PageBase &subPage) +{ + // Following the first three bytes already written by writeSubPageStart, + // the next three bytes for pages with X/26 packets are 0xca + // then little-endian offset to start of Level 1 page data. + // For pages with no X/26 packets, just three zeroes. + QByteArray offsetData(4, 0x00); + + int numOfX26Packets = 0; + + if (subPage.packetExists(26, 0)) { + offsetData[0] = 0xca; + + while (subPage.packetExists(26, numOfX26Packets)) + numOfX26Packets++; + + const int level1Offset = numOfX26Packets * 40 + 4; + offsetData[1] = level1Offset & 0xff; + offsetData[2] = level1Offset >> 8; + } + + writeRawData(offsetData.constData(), 3); + + // We should really re-implement writeX26Packets but I can't be bothered + // to count the number of X/26 packets twice... + if (numOfX26Packets > 0) { + // Reuse offsetData for this 4-byte header of the enhancement data + // Bytes are 0xc2, 0x00, then little-endian length of enhancement data + offsetData[0] = 0xc2; + offsetData[1] = 0x00; + offsetData[2] = (numOfX26Packets * 40) & 0xff; + offsetData[3] = (numOfX26Packets * 40) >> 8; + writeRawData(offsetData.constData(), 4); + + for (int i=0; ifileDialogFilter()); + if (i < s_nativeSize) { + if (i != 0) + s_filters.append(";;"); + s_filters.append(s_fileFormat[i]->fileDialogFilter()); + } + } + } + + s_instances++; +} + +SaveFormats::~SaveFormats() +{ + s_instances--; + + if (s_instances == 0) + for (int i=s_size-1; i>=0; i--) + delete s_fileFormat[i]; +} + +SaveFormat *SaveFormats::findFormat(const QString &suffix) const +{ + // TTI is the only official save format at the moment... +// for (int i=0; iextensions().contains(suffix, Qt::CaseInsensitive)) +// return s_fileFormat[i]; + + if (s_fileFormat[0]->extensions().contains(suffix, Qt::CaseInsensitive)) + return s_fileFormat[0]; + + return nullptr; +} + +SaveFormat *SaveFormats::findExportFormat(const QString &suffix) const +{ + for (int i=0; iextensions().contains(suffix, Qt::CaseInsensitive)) + return s_fileFormat[i]; + + return nullptr; +} diff --git a/src/qteletextmaker/saveformats.h b/src/qteletextmaker/saveformats.h new file mode 100644 index 0000000..52defaf --- /dev/null +++ b/src/qteletextmaker/saveformats.h @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2020-2025 Gavin MacGregor + * + * This file is part of QTeletextMaker. + * + * QTeletextMaker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QTeletextMaker is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QTeletextMaker. If not, see . + */ + +#ifndef SAVEFORMATS_H +#define SAVEFORMATS_H + +#include +#include +#include +#include + +#include "document.h" +#include "levelonepage.h" +#include "pagebase.h" + +class SaveFormat +{ +public: + virtual ~SaveFormat() { }; + + virtual void saveAllPages(QSaveFile &outFile, const TeletextDocument &document); + virtual void saveCurrentSubPage(QSaveFile &outFile, const TeletextDocument &document); + + virtual QString description() const =0; + virtual QStringList extensions() const =0; + QString fileDialogFilter() const { return QString(description() + " (*." + extensions().join(" *.") + ')'); }; + virtual bool getWarnings(const PageBase &subPage) { return false; }; + QStringList warningStrings() const { return m_warnings; }; + QString errorString() const { return m_error; }; + +protected: + virtual void writeDocumentStart() { }; + virtual void writeAllPages(); + virtual void writeSubPage(const PageBase &subPage, int subPageNumber=0); + virtual void writeSubPageStart(const PageBase &subPage, int subPageNumber=0) { }; + virtual void writeSubPageBody(const PageBase &subPage); + virtual void writeSubPageEnd(const PageBase &subPage) { }; + virtual void writeDocumentEnd() { }; + + virtual void writeX27Packets(const PageBase &subPage); + virtual void writeX28Packets(const PageBase &subPage); + virtual void writeX26Packets(const PageBase &subPage); + virtual void writeX1to25Packets(const PageBase &subPage); + + virtual QByteArray format7BitPacket(QByteArray packet) { return packet; }; + virtual QByteArray format4BitPacket(QByteArray packet) { return packet; }; + virtual QByteArray format18BitPacket(QByteArray packet) { return packet; }; + + virtual int writePacket(QByteArray packet, int packetNumber, int designationCode = -1); + virtual int writeRawData(const char *s, int len); + + TeletextDocument const *m_document; + QSaveFile *m_outFile; + QDataStream m_outStream; + QStringList m_warnings; + QString m_error; +}; + +class SaveTTIFormat : public SaveFormat +{ +public: + QString description() const override { return QString("MRG Systems TTI"); }; + QStringList extensions() const override { return QStringList { "tti", "ttix" }; }; + +protected: + virtual void writeDocumentStart(); + virtual void writeSubPageStart(const PageBase &subPage, int subPageNumber=0); + virtual void writeSubPageBody(const PageBase &subPage); + + virtual int writePacket(QByteArray packet, int packetNumber, int designationCode = -1); + void writeString(const QString &command); + + QByteArray format7BitPacket(QByteArray packet); + QByteArray format4BitPacket(QByteArray packet) { return format18BitPacket(packet); }; + QByteArray format18BitPacket(QByteArray packet); +}; + +class SaveM29Format : public SaveTTIFormat +{ +protected: + void writeSubPageStart(const PageBase &subPage, int subPageNumber=0); + void writeSubPageBody(const PageBase &subPage); +}; + +class SaveT42Format : public SaveFormat +{ +public: + QString description() const override { return QString("t42 packet stream"); }; + QStringList extensions() const override { return QStringList { "t42" }; }; + +protected: + void writeSubPageStart(const PageBase &subPage, int subPageNumber=0); + virtual int writePacket(QByteArray packet, int packetNumber, int designationCode = -1); + + virtual QByteArray format7BitPacket(QByteArray packet); + virtual QByteArray format4BitPacket(QByteArray packet); + virtual QByteArray format18BitPacket(QByteArray packet); + + int m_magazineNumber; +}; + +class SaveHTTFormat : public SaveT42Format +{ +public: + QString description() const override { return QString("HMS SD-Teletext HTT"); }; + QStringList extensions() const override { return QStringList { "htt" }; }; + +protected: + int writeRawData(const char *s, int len) override; +}; + +class SaveEP1Format : public SaveFormat +{ +public: + QString description() const override { return QString("Softel EP1"); }; + QStringList extensions() const override { return QStringList { "ep1" }; }; + virtual bool getWarnings(const PageBase &subPage); + +protected: + void writeSubPageStart(const PageBase &subPage, int subPageNumber=0); + void writeSubPageBody(const PageBase &subPage); + void writeSubPageEnd(const PageBase &subPage); + + virtual void writeX1to25Packets(const PageBase &subPage); + + virtual QByteArray format18BitPacket(QByteArray packet); + + // Language codes unique to EP1 + // FIXME duplicated in loadformats.h + const QMap m_languageCode { + { 0x00, 0x09 }, { 0x01, 0x0d }, { 0x02, 0x18 }, { 0x03, 0x11 }, { 0x04, 0x0b }, { 0x05, 0x17 }, { 0x06, 0x07 }, + { 0x08, 0x14 }, { 0x09, 0x0d }, { 0x0a, 0x18 }, { 0x0b, 0x11 }, { 0x0c, 0x0b }, { 0x0e, 0x07 }, + { 0x10, 0x09 }, { 0x11, 0x0d }, { 0x12, 0x18 }, { 0x13, 0x11 }, { 0x14, 0x0b }, { 0x15, 0x17 }, { 0x16, 0x1c }, + { 0x1d, 0x1e }, { 0x1f, 0x16 }, + { 0x21, 0x0d }, { 0x22, 0xff }, { 0x23, 0xff }, { 0x26, 0x07 }, + { 0x36, 0x1c }, { 0x37, 0x0e }, + { 0x40, 0x09 }, { 0x44, 0x0b } + }; +}; + + +class SaveFormats +{ +public: + SaveFormats(); + ~SaveFormats(); + + SaveFormat *findFormat(const QString &suffix) const; + SaveFormat *findExportFormat(const QString &suffix) const; + QString filters() const { return s_filters; }; + QString exportFilters() const { return s_exportFilters; }; + bool isExportOnly(const QString &suffix) const { return findFormat(suffix) == nullptr; }; + +private: + static const inline int s_size = 4; + static const inline int s_nativeSize = 1; + static int s_instances; + inline static SaveFormat *s_fileFormat[s_size]; + inline static QString s_filters, s_exportFilters; +}; + +#endif