I've developed a solution in .NET Core which I'm not going to accept (still waiting for a better answer).
The problem with this solution is that cropping isn't lossless (JPEGs are re-compressed) and ImageSharp somehow damages image metadata - the Date Taken time is displayed as 00:00 in XNView - I had to fix it using ExifTool by copying metadata from camera JPEGs.
Otherwise, it does what I need.
Images are placed in subfolders of a path:
/S - camera JPEGs with reduced resolution
/L - re-developed JPEGs from RAWs with full resolution
/T - target folder for cropped full resolution images
using System;
using System.IO;
using System.Linq;
using ImageSharp;
using SixLabors.Primitives;
namespace fix
{
class Program
{
static void Main(string[] args)
{
var smalls = Directory.GetFiles("path/S").OrderBy(p => p);
var tPath = "path/T/";
foreach (var path in smalls)
{
using (FileStream sStream = File.OpenRead(path))
// change path to full resolution JPEGs from RAWs
using (FileStream lStream = File.OpenRead(path.Replace("/S/", "/L/")))
{
var small = Image.Load(sStream);
var large = Image.Load(lStream);
Rectangle rect = new Rectangle();
// image height of the reduced resolution image with in-camera cropping
// it's always height, the portrait (2:3) mode is only in EXIF
if (small.Height == 1360)
{
// crop rectangle on the full resolution image (see above)
rect = new Rectangle(0, 168, 4000, 2664);
}
// this file has been cropped
if (rect.Width != 0)
{
using (FileStream tStream = File.OpenWrite(string.Format("{0}{1}", tPath, Path.GetFileName(path))))
{
var encoder = new ImageSharp.Formats.JpegEncoder();
encoder.Quality = 98; // as desired
large.Crop(rect).Save(tStream, encoder);
}
}
}
}
}
}
}